Python+OpenCV+dlibで居眠りを検出してArduinoでエアコンの設定温度を下げる

オフィスでの居眠りを監視し、眠そうな人がいたら空調の温度が下がって強制的に起こす、という空調管理システムが話題になっていました。

www.nikkei.com

自分はよくなにかやりながら寝てしまう体質で、家でゲームやってたり本読んでるときでも寝てしまいます。眠い状態を経由せずに覚醒状態からいつのまにか寝てるので、我慢は無力です。なのでAI等の第三者が冷気とかでやさしく起こしてくれるならそれに越したことはないです。なので作ることにしました。

↑居眠りするとアラームを鳴らす装置というのは市販されているけどこっちの方が奴隷っぽくてきつい

居眠りを検出する

居眠りはどうやって検出したらよいのでしょうか。日経の記事には「まぶたを持ち上げようとするゆっくりとした動きを分析する」とありますが、なんか難しそうなので、シンプルに「一定時間以上目を閉じていたら寝ている」と判断することにしました。
目つむりの判定をネットで検索してもあまり情報がありませんでしたが、「まばたきの検出」にキーワードを変えると先行事例が見つかりました。こちらを真似してみます。
www.pyimagesearch.com

リンク先ではOpenCVという画像処理ライブラリおよび機械学習ライブラリのdlibを使用し、顔のパーツを検出しています。

f:id:slideglide:20180727232259p:plain
Eye blink detection with OpenCV, Python, and dlib より引用

検出したパーツのうち目の形状に注目し、そのサイズの縦横比によってまばたきを検出しているようです。

f:id:slideglide:20180727232324p:plain
Eye blink detection with OpenCV, Python, and dlib より引用

まばたきは一瞬ですが、判定時間を引き延ばせば、そのまま居眠りの検出もできそうです。

同じものを動かす

というわけでまずは元記事と同じものを動かしてみます。ちなみにOSはWindows10です。
このページを参考にAnacondaでPythonとOpenCVをインストールしました。
それから追加で必要なライブラリを入れます。

conda install scipy
conda install --channel,https://conda.anaconda.org/pjamesjoyce,imutils
conda install -c conda-forge dlib

環境構築は以上です。元記事にはソースコードが載っていませんが、メールアドレスを登録するとDLすることができます。機械学習を使用していますが学習済みモデルがついてくるので自分で用意する必要はありません。それらを適当な場所において実行するのですが、その前に…

vs = VideoStream(src=0).start()    #<---これ
# vs = VideoStream(usePiCamera=True).start()
fileStream = False    #<---これ

detect_blinks.pyの66行目と68行目のコメントアウトを外します。こうしておくとWebカメラが使用できるようになります。
起動してみます。

python detect_blinks.py --shape-predictor shape_predictor_68_face_landmarks.dat

www.youtube.com

いとも簡単にまばたきの検出ができました。すごい!
(オリジナルのコードだと目の枠は常に緑ですが、わかりやすいようにまばたき検出時は青くしています)

動画の後半で試していますが、縦横比で測定しているため、俯いたりメガネのフレームに目が重なったりすると、目を開けていても閉じていると判定されることがあります。しかし俯いているときはだいたい居眠りしているときなので、今回のケースでは問題ないでしょう。
ちなみにうまく検出できない場合は、44行目のEYE_AR_THRESHの値を変えると、まばたきと判定される縦横比の閾値を変えられます。デフォルトは0.3ですが、僕の場合は0.22くらいでちょうどいい感じでした。

Arduinoとつなぐ

さて次に、この検出結果をパソコンの外に出すことを考えます。最終的にはエアコンを制御したいのですが、前段階としてとりあえずLEDをつけてみることにします。
ハードウェアはArduinoを使い、シリアル通信で制御します。

【永久保証付き】Arduino Uno

【永久保証付き】Arduino Uno

僕の環境ではpyserialのインストールが必要でした。

conda install pyserial

自分は普段Pythonを使わないので、Arduinoと通信させるのも初めてです。とりあえずテスト用に適当に書いたコードを動かしてみます。
python側はこう

# -*- coding: utf-8 -*-
import serial
import time

com_num = 'COM3' # Arduinoを繋いでるCOMポート番号

def main():

    ser = serial.Serial(com_num, 9600, timeout=0)
    
    time.sleep(10)
    ser.write(b'1')
    time.sleep(10)
    ser.write(b'0')
    ser.close()

if __name__ == '__main__':
    main()

Arduino側はこうしました。

void setup(){
  pinMode(10, OUTPUT);
  Serial.begin(9600);
}

void loop(){
  
  int input;
  input = Serial.read();
 
  if(input != -1 ){
    switch(input){
      case '0':
        digitalWrite(10, LOW);
        break;
      case '1': 
        digitalWrite(10, HIGH);
        break;
    }
  }
}

回路は、デジタルピン10番-LED-抵抗47Ω-GND、の順に接続します。
動かしてみると、LEDが一旦ついて、しばらくすると消えます。これでテストは成功です!
はまりポイントとしては、serial.Serial()したタイミングでArduinoが一度リセットされます。さいしょ誤動作かと思って修正を試みたのですが、どうも「そういうもの」らしいです。

続いて、さっきの顔検出のコードを改造していきましょう。一定時間目をつぶっているとLEDが点くように。つまり居眠り中はLEDが点くようにしました。

www.youtube.com

いい感じですね!

Arduinoから赤外線リモコンを発信

さいごに、Arduino側でエアコンのリモコンを実装します。このページにすべてが書いてあるので、やってみたい方は参考にしてください。
ちなみに設定温度を変更するには単に「上げ」「下げ」の信号ではなく、現在の設定温度なども加味して信号を送る必要があるみたいです(ちゃんと調べてないけど雰囲気的に)。
それはちょっと大変なので、今回は「起きているときは除湿運転」「寝たら冷房に切り替え」で気温を変えることにします。

#include <IRremote.h>
IRsend irsend;

#define ledpin 4

//highten
unsigned int signalHighten[327] = {4436, 4448, 608, 1552, 580, 1580, 580, 1576, 584, 1576, 580, 500, 580, 504, 580, 1576, 580, 512, 580, 504, 580, 500, 580, 500, 580, 500, 580, 1576, 584, 1576, 580, 504, 576, 1592, 580, 500, 580, 504, 576, 504, 580, 500, 580, 500, 580, 1580, 580, 500, 580, 512, 580, 1580, 576, 1580, 580, 1580, 580, 1576, 580, 1580, 580, 500, 580, 1580, 580, 1592, 576, 504, 576, 504, 580, 500, 580, 500, 580, 1580, 580, 500, 580, 500, 580, 1592, 576, 1584, 576, 504, 552, 1604, 580, 504, 552, 528, 552, 528, 552, 528, 552, 544, 552, 528, 552, 528, 552, 528, 552, 528, 556, 524, 552, 532, 552, 532, 548, 1616, 552, 532, 552, 528, 552, 528, 552, 528, 552, 528, 552, 532, 552, 528, 552, 540, 552, 528, 552, 528, 552, 1608, 552, 1604, 556, 528, 528, 552, 528, 552, 528, 564, 528, 1632, 528, 552, 528, 556, 548, 1608, 528, 1628, 528, 556, 504, 576, 504, 580, 524, 8044, 4360, 4528, 504, 1656, 504, 1656, 504, 1652, 504, 1652, 508, 576, 504, 576, 504, 1652, 508, 588, 504, 576, 504, 576, 504, 576, 508, 572, 508, 1652, 508, 1652, 504, 576, 508, 1660, 508, 576, 504, 576, 504, 576, 508, 572, 508, 576, 504, 1652, 508, 572, 508, 584, 508, 1652, 508, 1652, 504, 1652, 508, 1652, 508, 1652, 504, 576, 504, 1652, 508, 1664, 508, 572, 508, 572, 508, 576, 504, 576, 508, 1648, 508, 576, 504, 576, 508, 1660, 508, 1652, 508, 572, 508, 1652, 528, 552, 532, 548, 532, 552, 528, 552, 528, 564, 532, 548, 532, 548, 532, 548, 532, 552, 532, 548, 532, 548, 532, 548, 536, 1636, 532, 548, 536, 544, 536, 548, 556, 524, 556, 524, 560, 520, 560, 520, 560, 532, 560, 524, 560, 520, 560, 1596, 560, 1600, 560, 520, 560, 524, 560, 520, 560, 532, 560, 1596, 564, 520, 560, 520, 560, 1596, 564, 1596, 564, 516, 564, 520, 560, 524, 564};
//lower
unsigned int signalLower[327] = {4444, 4444, 588, 1568, 592, 1568, 588, 1572, 588, 1568, 588, 496, 588, 492, 588, 1568, 588, 508, 584, 496, 588, 492, 588, 492, 588, 492, 588, 1572, 588, 1568, 588, 496, 584, 1584, 588, 496, 584, 496, 584, 496, 588, 492, 588, 492, 588, 1572, 584, 496, 584, 508, 588, 1572, 584, 1572, 588, 1572, 584, 1576, 584, 1572, 588, 496, 584, 1572, 588, 1584, 584, 496, 584, 496, 584, 500, 580, 500, 580, 1576, 584, 500, 580, 500, 580, 1588, 584, 1576, 584, 496, 584, 1576, 580, 500, 584, 1572, 584, 500, 580, 500, 580, 512, 580, 500, 584, 500, 580, 500, 580, 500, 580, 500, 580, 500, 580, 1580, 580, 512, 580, 500, 580, 504, 580, 500, 580, 500, 580, 500, 580, 500, 580, 504, 576, 516, 580, 500, 580, 1576, 556, 528, 552, 1604, 556, 528, 552, 528, 552, 528, 552, 540, 556, 1604, 552, 1604, 556, 1604, 556, 1600, 556, 528, 552, 528, 556, 1600, 556, 1608, 556, 8012, 4408, 4480, 552, 1604, 556, 1604, 556, 1604, 528, 1628, 532, 552, 528, 552, 528, 1628, 528, 568, 504, 576, 508, 572, 528, 552, 528, 556, 504, 1652, 508, 1652, 504, 576, 508, 1660, 508, 576, 504, 576, 508, 572, 508, 572, 508, 576, 504, 1652, 508, 572, 508, 584, 508, 1652, 508, 1652, 504, 1652, 508, 1652, 508, 1648, 508, 576, 504, 1652, 508, 1664, 508, 572, 508, 572, 508, 576, 504, 576, 508, 1648, 508, 576, 504, 576, 508, 1660, 508, 1652, 508, 572, 508, 1652, 508, 572, 508, 1652, 504, 576, 508, 572, 508, 584, 508, 576, 504, 576, 528, 552, 528, 552, 532, 548, 532, 548, 532, 1628, 532, 560, 532, 548, 532, 552, 528, 552, 532, 548, 532, 548, 532, 548, 532, 552, 528, 564, 532, 548, 532, 1628, 532, 548, 532, 1624, 532, 552, 532, 548, 556, 524, 556, 536, 556, 1604, 556, 1600, 560, 1600, 560, 1600, 556, 524, 556, 524, 560, 1600, 556, 1604, 560};

void setup(){
  pinMode(ledpin, OUTPUT);
  Serial.begin(9600);
}

void loop(){
  
  int input;
  input = Serial.read();
 
  if(input != -1 ){
    switch(input){
      case '0':
        digitalWrite(ledpin, LOW);
        irsend.sendRaw(signalLower, sizeof(signalLower) / sizeof(signalLower[0]), 38);
        delay(1000);
        break;
      case '1': 
        digitalWrite(ledpin, HIGH);
        irsend.sendRaw(signalHighten, sizeof(signalHighten) / sizeof(signalHighten[0]), 38);
        delay(1000);
        break;
    }
  }
}

Python側のコードですが、登録者だけがDLできるコードを基にしているため全部載せるとまずそうです。改造箇所だけ載せます。

最初のimportとその後の初期化

# import the necessary packages
from scipy.spatial import distance as dist
from imutils.video import FileVideoStream
from imutils.video import VideoStream
from imutils import face_utils
import numpy as np
import argparse
import imutils
import time
import dlib
import cv2
import serial
import time

PORT = 'COM3'
ser = serial.Serial(PORT, 9600, timeout=1)
time.sleep(10)


元ソースでいうと41行目から。

# define two constants, one for the eye aspect ratio to indicate
# blink and then a second constant for the number of consecutive
# frames the eye must be below the threshold
EYE_AR_THRESH = 0.22
EYE_AR_CONSEC_FRAMES = 15

# initialize the frame counters and the total number of blinks
COUNTER = 0
TOTAL = 0
IS_OPEN = False

元ソースでいうと103行目から。

		# average the eye aspect ratio together for both eyes
		ear = (leftEAR + rightEAR) / 2.0

		# compute the convex hull for the left and right eye, then
		# visualize each of the eyes
		leftEyeHull = cv2.convexHull(leftEye)
		rightEyeHull = cv2.convexHull(rightEye)
		if ear < EYE_AR_THRESH:
			cv2.drawContours(frame, [leftEyeHull], -1, (255, 0, 0), 1)
			cv2.drawContours(frame, [rightEyeHull], -1, (255, 0, 0), 1)
		else:
			cv2.drawContours(frame, [leftEyeHull], -1, (0, 255, 0), 1)
			cv2.drawContours(frame, [rightEyeHull], -1, (0, 255, 0), 1)
		COUNTER += 1

		# check to see if the eye aspect ratio is below the blink
		# threshold, and if so, increment the blink frame counter
		if ear < EYE_AR_THRESH:
			if IS_OPEN == False:
				COUNTER = 0
			elif COUNTER == EYE_AR_CONSEC_FRAMES:
				IS_OPEN = False
				ser.write(b'1')

		# otherwise, the eye aspect ratio is not below the blink
		# threshold
		else:
			# if the eyes were closed for a sufficient number of
			# then increment the total number of blinks
			if IS_OPEN == True:
				COUNTER = 0
			elif COUNTER == EYE_AR_CONSEC_FRAMES:
				IS_OPEN = True
				ser.write(b'0')

		# draw the total number of blinks on the frame along with
		# the computed eye aspect ratio for the frame
		cv2.putText(frame, "Count: {}".format(COUNTER), (10, 30),
			cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2)
		cv2.putText(frame, "Eyes opened: {}".format(IS_OPEN), (10, 80),
			cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2)
		cv2.putText(frame, "EAR: {:.2f}".format(ear), (300, 30),
			cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2)

COUNTERまわりの処理を大きく変えているのは、誤検出で即座に切り替えてしまわないためです。
15フレームつづけて変更(瞼を閉じていた→開いた、またはその逆)を検出しないと、エアコンの信号は出しません。

f:id:slideglide:20180727230708j:plain

回路的には、デジタルピン3番-赤外線LED-抵抗47Ω-GND、でいいのではないかと思います。
写真の回路は信号を強くしようとしてトランジスタを入れていますが、効果があったのかどうかよくわかりませんでした。

実際の動作風景はこんな感じです。

www.youtube.com

せっかく2カメで撮影したのにエアコンに見た目の変化がなくてガッカリですが、「ピッ」という音がするので切り替わっていることだけはわかると思います。

かくして、うちにも「居眠りをするとAIが冷気で起こす」システムが配備されました!

使用感

数時間だけ運用してみたのですが、このレベルだと単に「起きた時に部屋が涼しい」だけであって、居眠り防止にはなりませんでした。
やはり目を閉じる以前、居眠りをしそうな段階で、空気を冷やさないとダメですね。

今回は全く使用していませんが不労所得が欲しいのでエアコンを操作できるIoTデバイスをいくつか貼っておきます。

LS Mini【Amazon Echo/Google Home対応製品】

LS Mini【Amazon Echo/Google Home対応製品】

お知らせ

8/4~5、Maker Faire Tokyoにて、恒例「技術力の低い人限定ロボコン(通称:ヘボコン)」のミニバージョンを開催します。今回は和がテーマの「和ヘボコン」です。和室に正座し、ちゃぶ台の上でロボを戦わせます。
portal.nifty.com
ほか、ヘボコン本やCapsule Cheeperの販売など。きてね。