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ヶ月無料の試用版があります。

