目覚めを検知する目覚まし時計(スマートアラーム)

# はじめに
朝苦手でいつも出勤ギリギリまで寝てしまうので、絶対に起きれる環境をソフトウェアで作ろうと思い、カメラから目覚めを検知するまでアラームが鳴り続ける目覚まし時計をRaspberryPiで作成しました。
目覚め検知には以下の記事で作成した眠気検知プログラムを使用しています。
[ラズパイカメラによる眠気検知とWebストリーミング](https://qiita.com/AkitoArai709/items/b8a23af4042de1aa0fdd)

本記事のソースコードは以下に置いています。
https://github.com/AkitoArai709/SmartAlarm

# 環境

* Raspberry Pi 4
* Camera Module V2 For raspberry pi
* ROUNDS portable speaker
* Python : 3.7.3
* opencv : 4.5.1.48
* dlib : 19.22.0
* imutils : 0.5.4
* scipy : 1.6.2

# システム構成

　設定されたアラーム時間になるとスピーカーからアラームを鳴らし、カメラを起動します。カメラから人物を検知し、顔の目の大きさから眠気状態を検知し、目覚め状態になるまでアラームを鳴らし続けます。
![SmartAlarm.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1425498/0857b57e-e2a2-94da-fb84-fff72834002d.png)

## ファイル構成
```
.
├─ models
│ ├─ dlib
│ │   └─ shape_predictor_68_face_landmarks.dat
│ └─ opencv
│     └─ haarcascade_frontalface_alt2.xml
├─ settings
│   └─ alarmSettings.json
├─ sounds
│   └─ <Alarm sound file>.wav
└─ src
    ├─ alarmSetting.py
    ├─ alarmSpeaker.py
    ├─ buffer.py
    ├─ detectionSleepiness.py
    ├─ main.py
    └─ smartAlarm.py
```

# スマートアラーム
　`main.py`では`smartAlarm.py`で実装されたスマートアラームアプリケーション(`SmartAlarm`クラス)を読み込んで実行します。

```python:main.py
#!/usr/bin/env python
""" main.py
    Summay:
        RaspberryPi smart alarm application.
"""

from smartAlarm import SmartAlarm

def main():
    alarm = SmartAlarm()

if __name__ == '__main__':
    main()
```

```python:smartAlarm.py
  
""" smartAlarm.py
    Summay:
        Smart Alarm application.
        This alarm will continue to sound until wake up.
"""

import json
import time
import threading
from alarmSetting import AlarmSetting
from alarmSpeaker import AlarmSpeaker
from datetime import datetime

class SmartAlarm:
    """ SmartAlram. """

    __SETTINGS_FILE = "./settings/alarmSettings.json"

    def __init__(self):
        """ Init constructor.
        """
        self.__isRunning = False
        self.__threadObj = None
        self.__alarmSettings = []
        self.alarmSpeaker = AlarmSpeaker()

        self.readJson()
        self.startAlarmThread()
        
    def __del__(self):
        """ Destructor.
        """
        self.stopAlarmThread()
        self.writeJson()

    def addAlarmSetting(self, time: time, days: list):
        """ Add Alarm setting.
            (Example)
                addAlarmSetting(datetime.strptime("07:00", '%H:%M').time(),['Sun','Mon','Tue','Web','Thu','Fri'])
        Args:
            time (time): Alarm time.
            days (list): Days of the weeks.
        """
        setting = AlarmSetting(time.replace(second=0, microsecond=0),days)
        self.__alarmSettings.append(setting)

    def startAlarmThread(self):
        """ Start AlarmThread.
        """
        self.__isRunning = True
        if self.__threadObj is None:
            self.__threadObj = threading.Thread(target=self.__alarmThread)
            self.__threadObj.start()

    def stopAlarmThread(self):
        """ Stop AlarmThread.
        """
        self.__isRunning = False
        if self.__threadObj is not None:
            self.__threadObj.join()
            self.__threadObj = None
    
    def writeJson(self):
        """ Write the alarm settings to json file.
        """
        # Serialize the AlarmSetting object.
        list = []
        for alarm in self.__alarmSettings:
           if isinstance(alarm, AlarmSetting):
                alarm: AlarmSetting = alarm
                list.append(alarm.serialize())

        # Convert to json, write file.
        with open(self.__SETTINGS_FILE, mode='w', encoding='utf-8') as file:
            json.dump(list, file, indent = 2)

    def readJson(self):
        """ Read the alarm settings to json file.
        """
        with open(self.__SETTINGS_FILE, mode='r') as file:
            list = json.load(file)
            for setting in list:
                alarmSetting = AlarmSetting.deserialize(setting)
                if alarmSetting is not None:
                    self.__alarmSettings.append(alarmSetting)

    def __alarmThread(self):
        """ Main thread of SmartAlarm class.
            Check the alarm settings.
            If it matches the setting time, go off the alarm.
        """
        while self.__isRunning:
            if self.__checkAlram():
                self.alarmSpeaker.goOff()

            # Sleep until next time.
            time.sleep(60 - datetime.now().second)

    def __checkAlram(self) -> bool:
        """ Check the alarm settings
        Returns:
            (bool): Judgment value of check the alarm settings.
        """
        retVal = False
        now = datetime.now()
        for alarm in self.__alarmSettings:
           if isinstance(alarm, AlarmSetting):
               alarm: AlarmSetting = alarm
               # Check the alarm setting
               if alarm.isSetTime(now):
                   retVal = True
                   alarm.setEnabled(False)
        return retVal
```

　`SmartAlarm`クラスはインスタンス生成時にアラーム再生クラス(`AlarmSpeaker`)のインスタンス生成、アラーム設定ファイル読み込み、アラームスレッドの起動を行い、アプリケーションの動作を開始します。`__alarmThread`スレッドではアラーム設定のチェック及び、アラームの再生処理の呼び出しを行います。また、アラーム設定時間は分単位としているため、アラーム時間のチェック処理実行完了後、次の分単位の時間になるまでSleepさせて、CPU使用率が無駄に増加しないように抑制しています。
　アラーム設定時間は`AlarmSetting`クラスで定義され、`__alarmSettings`のリストで管理します。リストで管理することで、複数個のアラームを設定可能としています。

## アラーム設定クラス

　`AlarmSetting`クラスではアラーム設定として、アラーム時間と曜日を定義します。

```python:alarmSetting.py
""" alarmSetting.py
    Summay:
        Alarm setting.
"""

from datetime import datetime, time

class AlarmSetting:
    """ AlarmSetting. """

    # Define properties.
    __PROP_TIME = "time"
    __PROP_DAYS = "days"
    __PROPERTIES = [__PROP_TIME, __PROP_DAYS]

    # Reset time of alarm setting.
    __RESET_TIME = datetime.strptime("00:00", '%H:%M').time()

    def __init__(self, time: datetime, days: list):
        """ Init constructor.
        """
        # self.time = time
        # self.days = days
        self.__dict__[self.__PROP_TIME] = time
        self.__dict__[self.__PROP_DAYS] = days

        self.__isEnabled = True
        self.__isAlreadyReset = False
    
    @property
    def time(self) -> time:
        """ time property.
        Returns:
            (time): datetime.time
        """
        return self.__dict__[self.__PROP_TIME]
    
    @property
    def days(self) -> list:
        """ days property.
        Returns:
            (list): days of week.
        """
        return self.__dict__[self.__PROP_DAYS]

    def isSetTime(self, now: datetime) -> bool:
        """ Check the setting time.
        Args:
            now (datetime): Current time.
        Returns:
            (bool): Judgment value of check the alarm setting.
        """
        self.__resetEnabledSetting(now.time())
        return self.__isSettingTime(now.time()) \
                and self.__isSettingDay(now.strftime('%a')) \
                and self.__isEnabled

    def setEnabled(self, enabled: bool):
        """ Set the enabled value.
        Args:
            enabled (bool): Setting value.
        """
        self.__isEnabled = enabled

    def serialize(self) -> dict:
        """ Serialize the AlarmSetting object.
            Return the dict type of the defined property.
        Returns:
            retVal (dict): Serialized value.
        """
        retVal = {}
        for key, value in self.__dict__.items():
            # Only defined properties.
            if self.__PROPERTIES in key:
                # The time type does not support json.
                # Convert time to str.
                if isinstance(value, time):
                    retVal[key] = value.strftime('%H:%M')
                else:
                    retVal[key] = value
        return retVal

    @classmethod
    def deserialize(cls, dict: dict):
        """ Deserialize the AlarmSetting object.
            Create the myself class from the dict data.
        Args:
            dict (dict): Setting data.
        Returns:
            (AlarmSetting): myself object.
        """
        # Return None if there is no defined property in the dict data.
        for prop in cls.__PROPERTIES:
            if prop not in dict:
                return None

        return cls(datetime.strptime(dict[cls.__PROP_TIME], '%H:%M').time(), dict[cls.__PROP_DAYS])


    def __isSettingTime(self, time: time) -> bool:
        """ Check the setting time.
        Args:
            time (time): Current time.
        Returns:
            (bool): Judgment value of check the setting time.
        """        
        return time.replace(second=0, microsecond=0) == self.time

    def __isSettingDay(self, day: str) -> bool:
        """ Check the setting day of the week.
        Args:
            day (str): Current day of the week.
        Returns:
            (bool): Judgment value of check the setting day of the week.
        """
        return day in self.days

    def __resetEnabledSetting(self, time: time):
        """ Reset the enabled value.
        Args:
            time (time): Current time.
        """
        time = time.replace(second=0, microsecond=0)

        # Matche the reset time and not yet reset.
        # Execute the reset process only during the reset time.
        if time == self.__RESET_TIME \
            and self.__isAlreadyReset == False:
            # Reset process.
            # Set enabled.
            self.setEnabled(True)
            self.__isAlreadyReset = True
        elif time != self.__RESET_TIME \
                and self.__isAlreadyReset == True:
            # Set false isAlreadyReset flg.
            self.__isAlreadyReset = False
```

　このクラスでは`serialize`と`deserialize`を用意してjson形式の変換を行えるようにしています。json形式の変換にはクラスの辞書型変数である`__dict__`を用い、変換対象のプロパティを`__PROPERTIES`で定義を行っています。こうすることで、json変換したいプロパティに変更があった場合でも、定数定義を変数するだけでプロパティを増減できる設計としています。
　`isSetTime`関数でアラーム有効度リセット(`__resetEnabledSetting`)と、現在日時・曜日のチェックを行い、全て一致時に`True`を返します。
　`__resetEnabledSetting`関数ではリセット時間(`00:00`)になると、アラームの有効度を`True`にして、アラーム実行で無効(`Flase`)なった設定を日付の変わるタイミングで有効(`True`)に戻します。また、`True`を設定する処理だけでは、アラーム時間がリセット時間と重なっている場合に、関数の使い方によっては、
「アラーム実行(`Flase`を設定)」→「有効度リセット(`True`を設定)」→「有効度が`True`のため、アラーム実行」...
と、同じ時間に複数回アラームが再生される可能性があるため、リセット時間に一度だけ`True`を設定するようにしています。

## アラーム設定ファイル

　アラーム設定として、アラーム時間(`time`)と曜日(`days`)をjson形式で定義します。

```json:alarmSetting.json
[
  {
    "time": "07:00",
    "days": [
      "Sun",
      "Mon",
      "Tue",
      "Web",
      "Thu",
      "Fri",
      "Sat"
    ]
  }
]
```

## アラーム再生クラス

　`AlarmSpeaker`クラスではアラーム再生処理と、眠気検知チェック処理を実行します。

```python:alarmSpeaker.py
""" alarmSleaker.py
    Summay:
        Alarm sleaker.
"""

import os
import cv2
import wave
import pyaudio
import random
import threading
from detectionSleepiness import DetectionSleepiness

class AlarmSpeaker:
    """ AlarmSpeaker. """

    # Sound path.
    __SOUNDS_DIR = "./sounds"

    def __init__(self):
        """ Init constructor.
        """
        self.__isRunning = False
        self.__speakerThreadObj = None
        self.__checkSleepinessThreadObj = None

    def __del__(self):
        """ Destructor.
        """
        self.stopThread()
     
    def goOff(self):
        """ Go off the alarm.
        """
        self.stopThread()
        self.startThread()

    def startThread(self):
        """ Start SpeakerThread and CheckSleepinessThread.
        """
        self.__isRunning = True
        if self.__speakerThreadObj is None:
            self.__speakerThreadObj = threading.Thread(target=self.__speakerThread)
            self.__speakerThreadObj.start()

        if self.__checkSleepinessThreadObj is None:
            self.__checkSleepinessThreadObj = threading.Thread(target=self.__checkSleepinessThread)
            self.__checkSleepinessThreadObj.start()

    def stopThread(self):
        """ Stop SpeakerThread and CheckSleepinessThread.
        """
        self.__isRunning = False
        if self.__speakerThreadObj is not None:
            self.__speakerThreadObj.join()
            self.__speakerThreadObj = None

        if self.__checkSleepinessThreadObj is not None:
            self.__checkSleepinessThreadObj.join()
            self.__checkSleepinessThreadObj = None

    def __checkSleepinessThread(self):
        """ Check sleepiness form the camera.
        """
        infApp = DetectionSleepiness()
        camera = cv2.VideoCapture(0)

        while self.__isRunning:
            _, frame = camera.read()
            if infApp.isSleepy(frame) == False:
                self.__isRunning = False

    def __speakerThread(self):
        """ Continue to sound music until stopped status.
        """
        sound = self.__SOUNDS_DIR + "/" + random.choice(os.listdir(self.__SOUNDS_DIR))

        while self.__isRunning:
            wf = wave.open(sound, "r")
            audio = pyaudio.PyAudio()
            stream = audio.open(format=audio.get_format_from_width(wf.getsampwidth()),
                        channels=wf.getnchannels(),
                        rate=wf.getframerate(),
                        output=True)

            data = wf.readframes(1024)

            while data != b'' and self.__isRunning:
                stream.write(data)
                data = wf.readframes(1024)
            
            stream.stop_stream()

            stream.close()
            audio.terminate()
            wf.close()
```

　このクラスではアラーム再生スレッド(`__speakerThread`)と眠気チェックスレッド(`__checkSleepinessThread`)の2つのスレッドを動作します。`__speakerThread`スレッドでは`__SOUNDS_DIR`ディレクトリに格納されたアラームファイルの再生を`__isRunning`が`False`になるまで繰り返し再生を行います。`__isRunning`は`__checkSleepinessThread`スレッドで目覚め状態を検知すると、`False`を設定します。つまり、目覚め状態になるまで、アラームを繰り返し再生続ける動作となります。

# 最後に
　今回はカメラから目覚めを検知する目覚まし時計を作成しました。普通の目覚まし時計とは違い、物理スイッチでアラームを止めることが出来ないので、より起きやすい環境を作ることはできたと感じています。課題としては、現状は目だけで判定しているため、目を無理やり開けると目覚め状態として検知してしまうので、顔全体で判定するアルゴリズムを考える必要があります。
　また、アラーム設定をjson形式のファイルに設定するようにしていますが、ファイルを編集するのは面倒なのでWebページから設定できるように改良しようと思います。