👁️

[OpenCV] 黒目の位置でマウスをスクロールするアプリを作ってみた

2022/11/30に公開

つくったもの

https://github.com/tanomitsu2002/opencv-eyetrack-scroll

目の位置をリアルタイムでトラッキングし、左を向いていたら下、右を向いていたら上にスクロールするアプリです。
セットアップの方法や実行方法はGithubにあるREADME.mdを参照してください。
README.mdにしたがって実行し、目を左右に動かすとマウスの上下スクロールが入力されます。

使ったパッケージ

参考

https://towardsdatascience.com/real-time-eye-tracking-using-opencv-and-dlib-b504ca724ac6

https://qiita.com/sassa4771/items/fbfb0012744350cf4d93

動機

両手で何か作業をしながらWEBページを読みたい!と普段から感じていたため作りました。
これで色々なZennの記事を読むのが捗ること間違いなしです。

大まかな流れ

基本的には参考記事の1つ目をそのまま使っています。
流れとしては、

黒目のトラッキング

  1. dlibget_frontal_face_detectorを使って得られるface detectorを使って顔の特徴店を抽出する
src/face/face_detect.py
face_detector = dlib.get_frontal_face_detector()
def get_one_face(gray_image: Mat) -> Mat | None:
    faces = face_detector(gray_image)
    if len(faces) == 0:
        return None
    face = faces[0]

    return face
  1. 適切な番号を指定して、目の輪郭の位置を(座標で)取得する
src/eye/eye_detect.py
gray_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
face = get_one_face(gray_frame)

display = frame.copy()

if face is not None:
    landmarks = predictor(gray_frame, face) # 特徴点を表す座標のlist(len=68)
  1. 2で求めた座標を元に、画像の目以外の領域を全て白く塗る
src/eye/eye_detect.py
def extract_eyes(frame: Mat, lps: list[Point], rps: list[Point]) -> Mat:
    # make mask with eyes white and other black
    mask = np.zeros(frame.shape[:2], dtype=np.uint8)
    mask = make_hole_on_mask(mask, lps)
    mask = make_hole_on_mask(mask, rps)
    mask = cv2.dilate(mask, np.ones((9, 9), np.uint8), 5)

    # attach mask on frame
    masked_frame = cv2.bitwise_and(frame, frame, mask=mask)
    masked_area = (masked_frame == bgr_black).all(axis=2)
    masked_frame[masked_area] = bgr_white
    return masked_frame # 目以外が白塗りされた画像
  1. 3の結果に2値処理を行う(すると、画像の中で黒目の部分のみが黒、それ以外は白になる)
src/cli.py
threshold = 30
_, thresh = cv2.threshold(
	eyes_frame_gray, threshold, 255, cv2.THRESH_BINARY
)
thresh_erode = cv2.erode(thresh, None, iterations=2)
thresh_dilate = cv2.dilate(thresh_erode, None, iterations=4)
thresh_blur = cv2.medianBlur(thresh_dilate, 3)
thresh_inv = cv2.bitwise_not(thresh_blur)
  1. 4の結果に輪郭検出をする(この時、輪郭の面積が最も大きいものを選ぶようにする)
src/eye/eye_detect.py
# 目の輪郭を検知して円を描画する関数
def get_contouring(
    thresh, face_mid_y, frame=None, is_right: bool = False
) -> Point | None:
    index = int(is_right)
    cnts, _ = cv2.findContours(
        thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE
    )
    try:
        cnt = max(
            cnts,
            key=lambda cnt: eval_contour(
                cnt, get_contouring.prev_points[index]
            ),
        )
        c = Point.from_contour(cnt)
        if c is None:
            raise ValueError("point is Null.")
        if is_right:
            c.x += face_mid_y
        get_contouring.prev_points[index] = c
        if frame is not None:
            cv2.circle(frame, (c.x, c.y), 2, bgr_blue, 2)
        return Point(c.x, c.y)
    except ValueError:
        pass
    except ZeroDivisionError:
        pass
  1. 輪郭の中心が黒目の中心と求められる

黒目の位置に応じてスクロール

  1. 目の中心を2の結果から目の中心を求める(具体的には、目の輪郭は6つの特徴点から表されるため、その中点を取る)
src/eye/eye_detect.py
def get_eye_center(points: list[Point]) -> Point:
    n = len(points)
    if n == 0:
        raise ValueError("List has no item.")
    sum_point: Point = Point(0, 0)
    for p in points:
        sum_point += p
    return sum_point // n
  1. 6で求めた黒目の中心と7で求めた目の中心の差分を計算する
  2. 8の差分に関数をかける(しきい値、スクロール速度の調整、etc.)

他に試したこと

1. 黒目の検出の評価に「前に黒目があった位置との距離」を加える

基本的に黒目の位置は高速で移動することは稀であると考えられるので、輪郭の面積の他に「前に黒目があった位置との距離」に負のスコア付けをすることで黒目の検出をより安定化できるのではと考えました。
というのも、黒目が目の端に移動すると黒目の大部分が皮膚の下に隠れてしまい、より輪郭面積の大きい目の縁が黒目として誤検知されてしまうという問題があり、このような誤検知が起こると(検出された)黒目の位置が2つの位置(真の黒目と目の縁)を行ったり来たりするようであったからです。

このようなモチベーションがあり、src/eye/eye_detect.pyeval_center関数で、alphaという重みで「前に黒目があった位置との距離」に負の優先順位を付けています。

しかし結果としてこれはボツ案(alpha = 0と設定している)にしました。理由は自分が思っている以上に目の縁が誤検知される問題への効果が薄く、また黒目の位置の追従性が著しく低下してしまったからです。

2. cv2.erodecv2.dilateiterationsを調整

cv2.erodecv2.dilate(documentation)はモルフォロジー変換を手軽に行える関数で、今回は目の周りを白く塗る時に使うmaskに対して使っています。

この関数の引数にあるiterationsはerosionやdilationを繰り返す回数であり、大きければ大きいほどその変換の影響が強く出ます。
特に今回のマスクに関しては、

  • erosionを行う→より狭い範囲を目として認識する
  • dilationを行う→より広い範囲を目として認識する

という影響が出るはずです。

このパラメータをいじろうと思ったモチベーションは上の1.と同じで、黒目が目の隅にある時に目の縁が黒目として認識されてしまう問題を解決したかったからです。
erodedilateiterationsを調整することで、目の縁がそもそも輪郭として認識されず、黒目だけを正しく追ってくれるようにしたかったというのが始まりです。

しかしこの調整は思った以上に難しく、

  • erosion多め→黒目が目の縁にある時に、目の縁だけでなく黒目も輪郭として認識されなくなる
  • dilate多め→目の縁が輪郭検出に引っかかることがある

というトレードオフになっていました。

パラメーターを色々調整した結果、erodeiterations2dilateiterations4という結果に落ち着きました。
しかし何度も言うようですが、この値は環境や使う機器によって最適値が微妙に変わるため、使いたい状況に合わせて調整してあげるのが大事です。

結果

左右を向いてみると、思った以上の安定性でスクロールすることがわかりました。
正直精度はあまり期待していなかったので、予想以上に実用的なものにできて嬉しいです。
スクロールの速度を下げるために意図的にフレームレートを落としているので、顔の特徴点検出をdlibのみを使う方法ではなくtensorflowなどを使った(より時間がかかるが高精度な)手法に置き換えるなどの改良も考えられると思います。
幸運なことに、今回参考にさせていただいたVardan Agarwalさんが下のような記事でより高精度な方法についても解説してくださっています。
https://towardsdatascience.com/robust-facial-landmarks-for-occluded-angled-faces-925e465cbf2e
こちらの方法はdlibの実装に比べてより横顔に強いとのことなので、試してみるのもいいかもしれません。
今回は黒目の検出ということで、顔が正面を向いていることを前提とした題材にしていたため、こちらの方法は試しませんでした。

最後まで読んでいただきありがとうございました!

Discussion