意識したまばたきと無意識のまばたきを見分ける — 閉眼時間でハンズフリー入力を作る

まばたき検出
📌 準備: OpenCV の環境構築がまだの方はこちら → C++ 環境構築ガイド / C#(OpenCvSharp)セットアップ

意識したまばたきと無意識のまばたきを見分ける — 閉眼時間でハンズフリー入力を作る

手を使わずに機器を操作したい——両手がふさがる作業中、手袋をしている現場、手指を動かしにくい方の操作補助、あるいはゲーム。そうした場面で「まばたきをクリックの代わりに使う」という発想は自然です。

ただし、まばたきを入力に使うには1つ大きな問題があります。人は意識しなくても、1分間に十数回まばたきをしていることです(安静時の平均 17回/分という報告があります:Bentivoglio ら, 1997)。まばたきをそのまま入力にすると、数秒おきに誤作動します。

本記事では、閉眼時間で意識したまばたきと無意識のまばたきを分ける設計を、動くコードと実測データで説明します。


1. 方式の候補

方式 操作 誤作動 操作する人の負担
まばたき1回 普通にまばたき 非常に多い(無意識のまばたきと区別できない) 小
2回連続(ダブルブリンク) 短い間隔で2回 中(無意識でも続けて2回することがある) 中
長めに閉じる 一定時間以上、目を閉じる 少ない 小〜中
片目だけ閉じる(ウインク) 左右どちらかだけ 少ない 大(できない人がいる)

本記事では「長めに閉じる」を採ります。無意識のまばたきは短く、意識して閉じると長くなる、という差を使います。片目の動作は誤作動に強い一方で、ウインクが苦手な人には使えません。

当社が公開しているブラウザゲーム「まばたきシューティング」も同じ考え方で、約0.35秒以上閉じたときだけ発射し、ふだんの短いまばたきでは発射しない設計にしています。


2. 設計で決めること

項目 本記事の値 考え方
入力とみなす閉眼時間 HOLD_S 0.35 秒 短すぎると無意識のまばたきで誤作動し、長すぎると操作が疲れる
発火のタイミング 閉眼が HOLD_S に達した瞬間 目を開けるのを待たないので反応が速い
入力ではないとみなす上限 MAX_S 2.0 秒 これ以上閉じ続けたら「目を休めている」「眠っている」とみなし、入力として数えない
開閉の判定 目の縦横比(EAR)+相対しきい値 詳細は OpenCV でまばたきを検出する(EAR 方式)と、その限界

発火のタイミングには2つの選び方があります。

タイミング 利点 欠点
閉眼が HOLD_S に達した瞬間(本記事) 反応が速い。操作した手ごたえがある 長く閉じた場合も、いったん発火してしまう
目を開けた瞬間に、閉眼時間が HOLD_S〜MAX_S なら発火 長い閉眼を確実に除外できる 目を開けるまで反応しない(体感で遅い)

ゲームや選択操作のように反応の速さが大事なら前者、取り消しのきかない操作(送信・決定など)なら後者が安全です。


3. コード(Python)

開閉の判定は、EAR 方式の記事と同じ YuNet + FacemarkLBF です。閉眼が続いたフレーム数から時間を出し、HOLD_S に達したら入力を発火します。

# 意識したまばたきだけを「入力」として拾う(閉眼時間で区別する)
# 開閉の判定は EAR 方式(YuNet で顔検出 → FacemarkLBF で68点 → 目の縦横比 → 直近の中央値×0.75 を下回ったら閉)
#   閉眼が HOLD_S 秒に達した瞬間に「入力」を1回発火する(目を開けるのを待たない=反応が速い)
#   HOLD_S に届かずに開いたものは「ふだんのまばたき」として数えるだけで、入力にしない
#   MAX_S 秒を超えて閉じ続けた場合は、入力ではなく「目を閉じて休んでいる」とみなして取り消す
# pip install "opencv-contrib-python>=4.10,<5"   ← cv2.face は contrib 版にだけ入っている
import sys
from collections import deque
from statistics import median

import cv2
import numpy as np


def eye_aspect_ratio(p, s):
    """目の6点から EAR を求める(Soukupová & Čech, 2016)"""
    d = lambda a, b: float(np.linalg.norm(p[a] - p[b]))
    return (d(s + 1, s + 5) + d(s + 2, s + 4)) / (2.0 * d(s, s + 3))


HOLD_S = 0.35   # これ以上閉じたら意識したまばたき(入力)
MAX_S = 2.0     # これ以上閉じ続けたら入力ではない(目を閉じて休んでいる・居眠り)


def main():
    if len(sys.argv) < 2:
        print("usage: python blink_input.py <video file | camera index>")
        return
    src = sys.argv[1]
    cap = cv2.VideoCapture(int(src) if src.isdigit() else src)
    if not cap.isOpened():
        print("cannot open", src)
        return
    fps = cap.get(cv2.CAP_PROP_FPS) or 30.0

    ratio = 0.75                    # 中央値の何割を下回ったら閉眼とみなすか
    history = deque(maxlen=round(fps * 3))  # 直近約3秒分の「開いている時」の EAR

    detector = cv2.FaceDetectorYN.create("face_detection_yunet_2023mar.onnx", "", (320, 320))
    facemark = cv2.face.createFacemarkLBF()
    facemark.loadModel("lbfmodel.yaml")

    frame_no = natural = inputs = 0
    closed_frames = 0      # 連続して閉じているフレーム数
    fired = False          # この閉眼で入力を発火したか(False: まだ / True: 発火済み / None: 休眼として取り消し済み)
    durations = []         # ふだんのまばたきの閉眼時間(秒)
    while True:
        ok, frame = cap.read()
        if not ok:
            break
        # 大きいフレームのままだと YuNet のスコアが下がって顔を見落とす → 幅640に縮めて検出し、座標を戻す
        scale = 640.0 / frame.shape[1] if frame.shape[1] > 640 else 1.0
        small = cv2.resize(frame, None, fx=scale, fy=scale)
        detector.setInputSize((small.shape[1], small.shape[0]))
        _, faces = detector.detect(small)

        ear = -1.0
        if faces is not None:
            box = np.round(faces[0][:4] / scale).astype(np.int32).reshape(1, 4)
            ok, landmarks = facemark.fit(frame, box)
            if ok and landmarks[0].shape[1] == 68:
                p = landmarks[0][0]
                ear = (eye_aspect_ratio(p, 36) + eye_aspect_ratio(p, 42)) / 2.0

        if ear > 0:
            now_closed = len(history) > 0 and ear < median(history) * ratio
            if now_closed:
                closed_frames += 1
                held = closed_frames / fps
                if fired is False and held >= HOLD_S:
                    fired = True
                    inputs += 1
                    print(f"INPUT #{inputs} at {frame_no / fps:.2f} s")
                if fired is True and held > MAX_S:
                    print(f"  ↳ {MAX_S} 秒を超えて閉じているため、入力ではなく休眼とみなす")
                    inputs -= 1
                    fired = None   # この閉眼の間はもう発火しない
            elif closed_frames:
                if fired is False:
                    natural += 1
                    durations.append(closed_frames / fps)
                closed_frames, fired = 0, False
            if not now_closed:  # 基準は「開いている時」の値だけで作る(長く閉じても基準が下がらない)
                history.append(ear)
        frame_no += 1
    print(f"frames={frame_no} fps={fps} 入力={inputs} ふだんのまばたき={natural}")
    if durations:
        print("ふだんのまばたきの閉眼時間(秒):", ", ".join(f"{d:.2f}" for d in durations))


if __name__ == "__main__":
    main()
必要なもの:
  pip install "opencv-contrib-python>=4.10,<5"
  face_detection_yunet_2023mar.onnx(OpenCV Zoo)、lbfmodel.yaml(Facemark の学習済みモデル)
実行例:
  python blink_input.py 0          ← カメラ
  python blink_input.py sample.avi

MAX_S を超えたときは、発火済みの入力を数のうえで取り消しているだけです。実際のアプリで「取り消せない操作」に使う場合は、§2 の「目を開けた瞬間に発火」に切り替えてください。


4. 実測: フレームレートが低いと、しきい値が効かない

意識して長く閉じる指示をしていない動画(まばたきを繰り返しているだけの動画・9fps)で、このコードを動かしました。

動画 短い閉眼として数えた回数 入力と判定された回数 短い閉眼の閉眼時間(秒)
PC 内蔵カメラ(14.4秒) 14 1 0.11〜0.33
スマートフォン(6.3秒) 5 0 0.11〜0.33

長く閉じる操作はしていない動画ですが、1回が入力と判定されました(目視では、ほかのまばたきと同じ程度の短いまばたきでした)。また、多くの閉眼時間が 0.11・0.22・0.33 秒と、111ms 刻みでしか出ていません。

原因はフレームレートです。9fps では1フレームが約111ms なので、

  • 閉眼時間は「閉じていたフレーム数 × 111ms」でしか測れない。実際は 0.25 秒程度のまばたきでも、フレームの境目のかかり方しだいで2フレーム(0.22秒)にも3フレーム(0.33秒)にも見え、閉じかけのフレームが閉と判定されればさらに1フレーム長くなる
  • しきい値 0.35 秒は「3.15フレーム」なので、実際には「4フレーム以上」の判定になり、1フレームの違いで入力になるかどうかが決まる

閉眼時間で区別する方式は、時間を細かく測れることが前提です。30fps なら1フレームは約33ms で、0.35 秒は約10フレーム分になり、1フレームのずれの影響は1割程度に収まります。

fps 1フレーム 0.35 秒は何フレームか 1フレームのずれの影響
9 111 ms 3.2 大きい(判定が揺れる)
15 67 ms 5.3 中
30 33 ms 10.5 小さい

入力に使うなら、実際に 30fps 前後が出ていることを確認してください。部屋が暗いとカメラは自動で fps を落とします(確かめ方は カメラでまばたきが反応しない・誤検出する原因)。


5. しきい値を人に合わせる

0.35 秒は出発点で、最適な値は人によって違います。実際に組み込むときは、次の手順で合わせるのが現実的です。

  1. ふだんのまばたきを30秒ほど記録し、閉眼時間の分布を見る(上のコードが最後に一覧を出します)
  2. ふだんのまばたきの最長値より十分長い値(目安として最長値の1.5〜2倍)を HOLD_S にする
  3. 実際に操作してもらい、「押したつもりなのに反応しない」と「押していないのに反応した」の回数で微調整する

操作に慣れると、意識した閉眼は短くなっていく傾向があります。長く使うアプリでは、しきい値を設定画面から変えられるようにしておくと親切です。


6. 使う場面ごとの注意

場面 注意点
ゲーム 反応の速さが大事。発火は HOLD_S 到達時。連打を想定し、入力後すぐに次を受け付ける
選択・決定の操作 誤作動の影響が大きいので、発火は目を開けた時に。決定前に確認の一手を挟む
長時間の作業 疲れてくると無意識の閉眼が長くなる。MAX_S で休眼を除外し、しきい値も見直せるように
操作補助(手指を動かしにくい方) 本人のまばたきの特徴に合わせた調整が必須。調整の手順を用意する

7. 閉眼時間を測るなら、開閉判定の安定性が前提

閉眼時間でまばたきを区別する設計は、「閉じている間ずっと閉眼と判定され続ける」ことが前提です。顔の特徴点から目の形を測る方式(EAR)では、閉じている途中で値が1フレームだけ戻り、1回の閉眼が2回に分かれることがあります。当社の検証でも、13回のまばたきのうち3回がこの形で分割されました。分割されると閉眼時間が短く測られ、意識したまばたきが HOLD_S に届かなくなります。

当社のまばたき検出SDKは、目の画像をディープラーニングのモデルに入れて開閉を判定する方式です。同じ動画では、分割による誤検出は0回でした。当社の動作確認用の動画3本での結果で、条件によって変わります。(比較の詳細)。下で紹介しているブラウザゲームも、このSDKで動いています。


まとめ

  • まばたきを入力に使う最大の問題は、無意識のまばたき(安静時で平均 17回/分という報告)
  • 「長めに閉じる」を入力にすると、誤作動を抑えつつ誰でも操作できる。本記事は HOLD_S = 0.35 秒
  • 発火は「閉眼が HOLD_S に達した瞬間」(速い)か「目を開けた瞬間」(安全)かを用途で選ぶ
  • 長すぎる閉眼(MAX_S 超え)は休眼として入力から除外する
  • 9fps では閉眼時間が 111ms 刻みになり、しきい値が効かない(実測で無操作でも1回誤作動)。30fps 前後を確保する

🎮 まばたきで操作する体験を試してみる

この設計で作ったブラウザゲーム「まばたきシューティング」を無料で公開しています。インストール・会員登録は不要で、PC・スマートフォンのブラウザで遊べます(カメラ映像は端末内で処理し、外部に送信しません)。判定にはまばたき検出SDKと同じ技術を使っており、精度は試用版アプリ(ブラウザ版)でも確かめられます。

→ まばたきシューティングで遊ぶ(無料)
→ 試用版アプリ(ブラウザ版)で精度を試す(無料)

タイトルとURLをコピーしました