OpenCV でまばたきを検出する(EAR 方式)と、その限界【C++ / C# / Python】

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

OpenCV でまばたきを検出する(EAR 方式)と、その限界【C++ / C# / Python】

カメラ映像からまばたきを数えたい——という場面で、最初に見つかる方法がほぼ必ず EAR(Eye Aspect Ratio=目の縦横比) です。顔の特徴点から目の縦と横の比を求め、それが小さくなったら「目を閉じた」とみなします。

本記事では EAR 方式を OpenCV だけで(dlib や MediaPipe を使わずに)実装し、実際の動画で数えた結果を出します。先に結論を書くと:

  • よく紹介される 固定しきい値(EAR < 0.2 前後)では、実測で13回中2回しか数えられませんでした
  • しきい値を「その人の直近の EAR の中央値の75%」という相対値にすると、13回中12回まで数えられました
  • ただし顔が小さく写る動画では、どの設定でも0回でした。特徴点が瞼の動きを追えていないためです

コードは C++ / C#(OpenCvSharp4)/ Python の3つを載せます。


1. EAR とは

EAR は Soukupová と Čech が 2016 年に発表した指標です(Real-Time Eye Blink Detection using Facial Landmarks, CVWW 2016)。目の輪郭の6点 p1〜p6 を使って次のように計算します。

EAR = ( |p2 - p6| + |p3 - p5| ) / ( 2 × |p1 - p4| )

  p1: 目尻(または目頭)   p4: 反対側の端
  p2, p3: 上瞼            p6, p5: 下瞼(p2 と p6、p3 と p5 が上下で対応)

目を開いているときは 0.3 前後、閉じると 0 に近づきます。横幅で割っているので、顔の大きさ(カメラからの距離)にはある程度左右されません。

68点の顔特徴点(いわゆる iBUG 300-W 形式)では、36〜41 番が画像の左側の目、42〜47 番が右側の目です。


2. 構成 — OpenCV だけで組む

段 使うもの 役割
顔検出 cv::FaceDetectorYN(YuNet・OpenCV 4.5.4 以降に標準搭載) 顔の矩形を得る
特徴点 cv::face::FacemarkLBF(opencv_contrib の face モジュール) 68点を得る
判定 EAR + しきい値 閉じた/開いたを決め、閉→開の切り替わりで1回と数える

必要なモデルファイルは2つです。

  • face_detection_yunet_2023mar.onnx(OpenCV Zoo の models/face_detection_yunet/・OpenCV 4.8 以降が必要。それより前の版では 2022mar のモデルを使う)
  • lbfmodel.yaml(OpenCV の Facemark チュートリアルで案内されている学習済みモデル・約54MB)

⚠️ cv::face は opencv_contrib に入っているモジュールです。Python なら opencv-python ではなく opencv-contrib-python を入れてください。入っていないと module 'cv2' has no attribute 'face' になります。


3. まず固定しきい値で数えてみる

検証には、当社が SDK の動作確認に使っている実写の動画3本を使いました。正解は、目元を1コマずつ切り出して筆者が目視で数えたものです。

動画 解像度 / fps 長さ 顔の幅 正解のまばたき
A(PC 内蔵カメラ・メガネ) 1280×960 / 9fps 14.4秒 400px 13回
B(スマートフォン・メガネ) 540×720 / 9fps 6.3秒 296px 5回
C(PC カメラ・メガネ・やや遠い) 640×480 / 12fps 7.5秒 169px 4回

まず、よく紹介される形——EAR が 0.21 を下回ったら閉眼、2フレーム以上続いたら1回——で数えました。

動画 正解 数えられた回数 誤検出
A 13 2 0
B 5 0 0
C 4 0 0

ほとんど数えられません。EAR の推移を見ると理由はすぐ分かります。

  • 閉じたときの EAR が 0.2 まで下がりきらない。 動画 A の開眼時は 0.31〜0.37 で、閉眼時の最小は(横を向いた場面の1回を除き)0.18〜0.23 でした。人によって開いている時の値が違い、閉じても特徴点が瞼に完全には追従しないため、「0.2」という絶対値では境目が合いません
  • フレームレートが低いと、閉じている時間が1〜2フレームしか写らない。 9fps では1フレームが約111ms です。まばたきは数百ミリ秒以下の短い動作なので、「2フレーム以上」という条件だけで多くを取りこぼします

4. 相対しきい値にする

そこでしきい値を「直近約3秒の EAR の中央値 × 0.75」に変え、1フレームでも下回れば閉眼とみなすようにしました。開いている時の値をその人・その状況から毎回取り直すので、個人差や照明による差を吸収できます。基準には目を開いていると判定したフレームの値だけを使います。閉じたフレームまで混ぜると、長く目を閉じたときに基準そのものが下がり、閉眼を見失うためです(中央値を使うのも、外れ値に引っぱられにくくするためです)。

判定のしかた A(13回) B(5回) C(4回)
固定 0.21・2フレーム以上 2回 / 誤検出0 0回 / 誤検出0 0回 / 誤検出0
固定 0.21・1フレーム以上 9回 / 誤検出1 1回 / 誤検出0 0回 / 誤検出0
固定 0.25・1フレーム以上 13回 / 誤検出4 5回 / 誤検出1 0回 / 誤検出3
相対(中央値×0.75)・1フレーム以上 12回 / 誤検出3 5回 / 誤検出0 0回 / 誤検出0

固定値を 0.25 に上げても数は増えますが、誤検出も増えます。相対しきい値は、動画 A と B では見落としと誤検出の両方が少なくなりました。動画 A の誤検出3件は、いずれも1回のまばたきを2回と数えたもの(閉じている途中で EAR が1フレームだけ戻った)で、見落とした1回は顔を横に向けた場面でした(§6)。「閉眼の間に開眼が1フレームだけ挟まったらつなげる」処理を足せば、分割はさらに減らせます。


5. コード

いずれも「動画ファイルのパス」または「カメラ番号」を引数に取り、まばたきを検出するたびに時刻を表示します。モデルファイル2つを実行ファイルと同じ場所に置いてください。

C++

// まばたき検出(EAR 方式・相対しきい値)
// YuNet で顔検出 → FacemarkLBF で68点 → 目の縦横比(EAR) → 直近の中央値より一定以上下がったら「閉じている」
#include <opencv2/opencv.hpp>
#include <opencv2/face.hpp>
#include <algorithm>
#include <cctype>
#include <cmath>
#include <deque>
#include <iostream>
#include <vector>

// 目の6点から EAR を求める(Soukupová & Čech, 2016)
static double eyeAspectRatio(const std::vector<cv::Point2f>& p, int s)
{
    auto d = [](cv::Point2f a, cv::Point2f b) { return std::hypot(a.x - b.x, a.y - b.y); };
    return (d(p[s + 1], p[s + 5]) + d(p[s + 2], p[s + 4])) / (2.0 * d(p[s], p[s + 3]));
}

static double median(std::deque<double> v)
{
    std::sort(v.begin(), v.end());
    return v[v.size() / 2];
}

int main(int argc, char** argv)
{
    if (argc < 2) {
        std::cerr << "usage: blink_ear <video file | camera index>\n";
        return 1;
    }
    const std::string src = argv[1];
    cv::VideoCapture cap;
    if (src.size() == 1 && std::isdigit(static_cast<unsigned char>(src[0]))) cap.open(src[0] - '0');
    else cap.open(src);
    if (!cap.isOpened()) { std::cerr << "cannot open " << src << "\n"; return 1; }
    const double fps = cap.get(cv::CAP_PROP_FPS) > 0 ? cap.get(cv::CAP_PROP_FPS) : 30.0;

    const double kRatio = 0.75;                                   // 中央値の何割を下回ったら閉眼とみなすか
    const size_t kWindow = static_cast<size_t>(std::round(fps * 3)); // 中央値をとる区間(約3秒)

    auto detector = cv::FaceDetectorYN::create("face_detection_yunet_2023mar.onnx", "", cv::Size(320, 320));
    auto facemark = cv::face::FacemarkLBF::create();
    facemark->loadModel("lbfmodel.yaml");

    std::deque<double> history;   // 直近の「開いている時」の EAR(基準づくり用)
    cv::Mat frame;
    int frameNo = 0, blinks = 0;
    bool closed = false;
    while (cap.read(frame)) {
        // 大きいフレームのままだと YuNet のスコアが下がって顔を見落とす → 幅640に縮めて検出し、座標を戻す
        const double scale = frame.cols > 640 ? 640.0 / frame.cols : 1.0;
        cv::Mat small;
        cv::resize(frame, small, cv::Size(), scale, scale);
        detector->setInputSize(small.size());
        cv::Mat faces;
        detector->detect(small, faces);

        double ear = -1.0;
        if (faces.rows > 0) {
            cv::Rect box(cvRound(faces.at<float>(0, 0) / scale), cvRound(faces.at<float>(0, 1) / scale),
                         cvRound(faces.at<float>(0, 2) / scale), cvRound(faces.at<float>(0, 3) / scale));
            std::vector<cv::Rect> rects{ box };
            std::vector<std::vector<cv::Point2f>> landmarks;
            if (facemark->fit(frame, rects, landmarks) && landmarks[0].size() == 68)
                ear = (eyeAspectRatio(landmarks[0], 36) + eyeAspectRatio(landmarks[0], 42)) / 2.0;
        }

        if (ear > 0) {
            const bool nowClosed = !history.empty() && ear < median(history) * kRatio;
            if (nowClosed && !closed) {
                ++blinks;
                std::cout << "blink #" << blinks << " at " << frameNo / fps << " s\n";
            }
            closed = nowClosed;
            if (!nowClosed) {               // 基準は「開いている時」の値だけで作る(長く閉じても基準が下がらない)
                history.push_back(ear);
                if (history.size() > kWindow) history.pop_front();
            }
        }
        ++frameNo;
    }
    std::cout << "frames=" << frameNo << " fps=" << fps << " blinks=" << blinks << "\n";
    return 0;
}
ビルド例(macOS / Linux):
  c++ -std=c++17 -O2 blink_ear.cpp -o blink_ear $(pkg-config --cflags --libs opencv4)
実行例:
  ./blink_ear sample.avi
  ./blink_ear 0          ← カメラ

C#(OpenCvSharp4)

NuGet で本体と、Windows 用のネイティブ部品の2つを入れます。2つ目を忘れると、実行時に Unable to load DLL 'OpenCvSharpExtern' で止まります。

dotnet add package OpenCvSharp4
dotnet add package OpenCvSharp4.runtime.win
// まばたき検出(EAR 方式・相対しきい値)C# / OpenCvSharp4 版
// YuNet で顔検出 → FacemarkLBF で68点 → 目の縦横比(EAR) → 直近の中央値より一定以上下がったら「閉じている」
using System;
using System.Collections.Generic;
using System.Linq;
using OpenCvSharp;
using OpenCvSharp.Face;

class BlinkEar
{
    // 目の6点から EAR を求める(Soukupová & Čech, 2016)
    static double EyeAspectRatio(Point2f[] p, int s)
    {
        static double D(Point2f a, Point2f b) => Math.Sqrt((a.X - b.X) * (a.X - b.X) + (a.Y - b.Y) * (a.Y - b.Y));
        return (D(p[s + 1], p[s + 5]) + D(p[s + 2], p[s + 4])) / (2.0 * D(p[s], p[s + 3]));
    }

    static void Main(string[] args)
    {
        if (args.Length < 1)
        {
            Console.Error.WriteLine("usage: BlinkEar <video file | camera index>");
            return;
        }
        using var cap = int.TryParse(args[0], out int cam) ? new VideoCapture(cam) : new VideoCapture(args[0]);
        if (!cap.IsOpened())
        {
            Console.Error.WriteLine($"cannot open {args[0]}");
            return;
        }
        double fps = cap.Fps > 0 ? cap.Fps : 30.0;

        const double ratio = 0.75;                      // 中央値の何割を下回ったら閉眼とみなすか
        int window = (int)Math.Round(fps * 3);          // 中央値をとる区間(約3秒)
        var history = new Queue<double>();

        using var facemark = FacemarkLBF.Create();
        facemark.LoadModel("lbfmodel.yaml");
        FaceDetectorYN detector = null;                 // OpenCvSharp には SetInputSize が無いので、入力サイズが決まってから作る

        using var frame = new Mat();
        using var small = new Mat();
        int frameNo = 0, blinks = 0;
        bool closed = false;
        while (cap.Read(frame) && !frame.Empty())
        {
            // 大きいフレームのままだと YuNet のスコアが下がって顔を見落とす → 幅640に縮めて検出し、座標を戻す
            double scale = frame.Width > 640 ? 640.0 / frame.Width : 1.0;
            Cv2.Resize(frame, small, new Size(), scale, scale);
            detector ??= FaceDetectorYN.Create("face_detection_yunet_2023mar.onnx", "", small.Size(), 0.9f, 0.3f, 5000);
            using var faces = new Mat();
            detector.Detect(small, faces);

            double ear = -1.0;
            if (faces.Rows > 0)
            {
                var box = new Rect((int)Math.Round(faces.At<float>(0, 0) / scale), (int)Math.Round(faces.At<float>(0, 1) / scale),
                                   (int)Math.Round(faces.At<float>(0, 2) / scale), (int)Math.Round(faces.At<float>(0, 3) / scale));
                using var rects = InputArray.Create(new[] { box });
                if (facemark.Fit(frame, rects, out Point2f[][] landmarks) && landmarks[0].Length == 68)
                    ear = (EyeAspectRatio(landmarks[0], 36) + EyeAspectRatio(landmarks[0], 42)) / 2.0;
            }

            if (ear > 0)
            {
                var sorted = history.OrderBy(v => v).ToArray();
                bool nowClosed = sorted.Length > 0 && ear < sorted[sorted.Length / 2] * ratio;
                if (nowClosed && !closed)
                {
                    blinks++;
                    Console.WriteLine($"blink #{blinks} at {frameNo / fps:F2} s");
                }
                closed = nowClosed;
                if (!nowClosed)                 // 基準は「開いている時」の値だけで作る(長く閉じても基準が下がらない)
                {
                    history.Enqueue(ear);
                    if (history.Count > window) history.Dequeue();
                }
            }
            frameNo++;
        }
        detector?.Dispose();
        Console.WriteLine($"frames={frameNo} fps={fps} blinks={blinks}");
    }
}

OpenCvSharp の FaceDetectorYN には C++ 版の setInputSize に当たるメソッドが無いため、入力サイズが決まった最初のフレームで生成しています。

Python

C++ / C# と同じ処理です。Python 版を載せるのは、「瞬き 検出 python」で探す方が多いためです。

# まばたき検出(EAR 方式・相対しきい値)Python 版
# YuNet で顔検出 → FacemarkLBF で68点 → 目の縦横比(EAR) → 直近の中央値より一定以上下がったら「閉じている」
# 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))


def main():
    if len(sys.argv) < 2:
        print("usage: python blink_ear.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 = blinks = 0
    closed = False
    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 and not closed:
                blinks += 1
                print(f"blink #{blinks} at {frame_no / fps:.2f} s")
            closed = now_closed
            if not now_closed:  # 基準は「開いている時」の値だけで作る(長く閉じても基準が下がらない)
                history.append(ear)
        frame_no += 1
    print(f"frames={frame_no} fps={fps} blinks={blinks}")


if __name__ == "__main__":
    main()

C++ 版と Python 版は、上の3本の動画で数えた回数が完全に一致しました(A: 15回=正解12回+1回を2回に分けた3回 / B: 5回 / C: 0回)。C# 版は Windows で実際に動かして照合しました。回数が mac と1〜2回ずれる理由は §6 に書きます。


6. ハマりどころ

大きいフレームのままだと顔を見落とす

動画 A(1280×960)は、最初はほぼ全フレームで顔が見つかりませんでした。YuNet の既定のスコアしきい値(0.9)に対して、原寸のままだとスコアが 0.894 で、わずかに届かなかったのが原因です。幅640に縮めて検出すると 0.943 になり、全フレームで検出できました。上のコードはすべて「縮めて検出 → 座標を元の大きさに戻す → 特徴点は原寸で取る」としています。

顔が小さいと、EAR が瞼の動きに追従しない

動画 C(顔の幅 169px=画面幅の26%)は、目視ではっきり目を閉じているフレームでも EAR がほとんど下がりませんでした。開眼時の中央値 0.29(0.28〜0.30)に対して、閉じているフレームでも 0.26〜0.27 までしか下がりません。顔は全フレームで検出できているので、問題は特徴点の精度です。この大きさだと目の高さは十数ピクセルしかなく、LBF の特徴点は瞼の上下を追いきれません。しきい値をどう工夫しても、元の値が動かない以上は数えられません。

実用上は、顔の幅が 250px 以上(300px あれば安定)写る距離・解像度にするのが前提になります。

頭を動かすと値が跳ねる

動画 A の終盤で顔を横に向けた場面では、EAR が一瞬 0.49 まで跳ね、その直後のまばたきを見落としました。横顔では目の横幅が縮むため、分母が小さくなって値が大きく出ます。中央値で基準を取っていれば基準は大きく崩れませんが、顔の向きが大きく変わる用途では、向きの推定と組み合わせるのが安全です。

動かす環境で回数が1〜2回変わる

同じ3本の動画を、Windows 上の C# 版(OpenCvSharp4)でも数えました。mac は OpenCV 4.14、Windows は OpenCvSharp4 4.13 に同梱の OpenCV です。結果は A が13回(mac の C++ / Python 版は15回)で、この差は1回を2回に分けた件数の違いです。拾えたまばたき自体は同じでした。B はどちらも5回でしたが、中身が違います。mac 版は5回すべてを正しく数えたのに対し、Windows 版は正しく数えたのが4回、1回を2回に分けたのが1回で、見落としが1回ありました。C は両方とも0回です。

顔の枠の差はほとんどのフレームで1px未満(最大でも10px弱)でしたが、それでも特徴点の位置はずれ、EAR はフレームあたり平均で0.01強、最大で0.1変わりました。しきい値すれすれのまばたきは、この程度の差で数えたり数えなかったりします。回数を比べるときは、同じ環境・同じ版の OpenCV で測った値どうしで比べてください。


7. EAR 方式の限界

ここまでの結果をまとめると、EAR 方式は条件が良ければ十分に使える一方で、次の弱点があります。

弱点 起きること 本記事での実例
特徴点の精度に依存 顔が小さい・ぼけている・メガネの反射があると瞼を追えない 動画 C は0回
しきい値が環境で変わる 固定値は人・照明・カメラごとに合わせ直しが要る 0.21 固定で13回中2回
フレームレートに依存 低 fps では閉眼が1フレームしか写らず、連続フレーム条件で取りこぼす 9fps で「2フレーム以上」が機能しない
1回を2回と数える 閉眼の途中で値が戻ると分割される 動画 A で3件
顔の向き 横を向くと横幅が縮み、値が跳ねる 動画 A の終盤で 0.49

EAR は「目の形の幾何学」から開閉を推定する方法です。これに対して、目の画像そのものから開閉を判定する方法(目元を切り出して学習済みモデルで分類する)もあります。特徴点が瞼を追えない条件でも、目の見た目から直接判定できるのが利点です。

同じ3本の動画で比べる

当社のまばたき検出SDKは、この「目の画像から判定する」方式です。顔と目の位置を検出したあと、目の画像をディープラーニングのモデルに入れ、左右の目が開いているか閉じているかを1フレームずつ判定します。まばたきは「両目が開いた状態から閉じた」ところで1回と数えます。

§3・§4 と同じ3本の動画・同じ正解で、当社のまばたき検出SDK(ver.1.1.1・mac・CPU のみ・OpenCV 4.14)でも数えました。

判定のしかた A(13回) B(5回) C(4回)
EAR・相対しきい値(§4) 12回 / 誤検出3 5回 / 誤検出0 0回 / 誤検出0
当社SDK(目の画像をディープラーニングで判定) 12回 / 誤検出0 5回 / 誤検出0 4回 / 誤検出0

いちばん差が出たのは、顔の幅が169pxしかない動画 C です。EAR 方式では特徴点が瞼を追えず0回でしたが、目の画像から判定する方式では4回すべてを数えました。動画 A で EAR 方式が出した「1回を2回と数える」誤検出も、出ていません。一方、動画 A の見落とし1回は当社SDKにもありました(EAR 方式が見落としたのとは別のまばたきです)。

ただし、比べたのは当社の動作確認用の3本だけです(これらの動画は当社モデルの学習には使っていません)。どんな条件でもこの結果になるとは限らないので、使う予定の環境で試してから判断してください。ブラウザ版の試用版アプリ(無料)なら、ご自身のカメラですぐに確かめられます。

うまく動かないときの原因の切り分けは カメラでまばたきが反応しない・誤検出する原因 に、意識したまばたきだけを入力に使う設計は 意識したまばたきと無意識のまばたきを見分ける にまとめました。


8. まとめ

  • EAR は目の6点から求める「縦横比」。OpenCV なら YuNet + FacemarkLBF で、dlib なしで組める
  • 固定しきい値 0.21 では実測で13回中2回。開眼時の値に個人差があり、閉じても 0.2 まで下がりきらない
  • 直近の中央値 × 0.75 の相対しきい値にすると、13回中12回(誤検出3・いずれも1回を2回と数えたもの)
  • 大きいフレームは縮めてから顔検出する(原寸ではスコアが 0.9 に届かず見落とした)
  • 顔の幅 169px ではどの設定でも0回。特徴点が瞼を追えないため、しきい値では救えない
  • 同じ3本で、目の画像をディープラーニングで判定する当社SDKは 22回中21回・誤検出0(顔の幅 169px の動画も4回すべて)

👁 まばたき検出を製品に組み込みたい方へ

本ブログを運営するスワローインキュベートのまばたき検出SDKは、特徴点ではなく目の画像から開閉を判定する方式で、一般的な可視光カメラと CPU だけで動作します。Windows / Linux / iOS / Android 対応・C++ / C#。1ヶ月無料の試用版があります。

→ まばたき検出SDKの詳細を見る
→ 試用版アプリ(ブラウザ版)で精度を試す(無料)

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