眠気を検知！現実にカムバック、居眠り防止アプリを作成！

# 眠気を覚ましたい！
私は仕事中や勉強中に眠いときに、眠気を覚ましたいなと思うことがよくあります。

今はコーヒーを飲んでいますが、学生の頃、授業で居眠り中、先生に声をかけられたときが１番目が覚めたので、今回はその体験をアプリで再現したいなと思います。


![スクリーンショット 2024-10-31 21.25.39.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3682615/b34f5bca-4707-37a8-9cda-19f2590aac68.png)


# 作成したもの
PCのインカメで自分の顔を映し、目を閉じたら音声が流れて起こされるアプリを作成しました。入力した名前が〇〇の場合、「そこの寝ている〇〇さん、起きなさい」という音声が流れます。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3682615/b0dd8cef-35fb-6884-2e4e-4e13de65fcaa.png" width="450"> 

目を閉じた判定はこちらの記事を参考にしました。

https://qiita.com/mogamin/items/a65e2eaa4b27aa0a1c23


# 実装

## 構成とコードまとめ
```
project_root/
├── app.py                                  # Flaskアプリケーションのメインファイル
├── shape_predictor_68_face_landmarks.dat   # Dlibのランドマークモデルファイル
└── templates/
    └── index.html                          # 名前入力フォームとカメラ映像のHTMLテンプレート
```
<details>
<summary>app.pyのコードまとめ</summary>

```app.py
from flask import Flask, render_template, request, Response, jsonify
import cv2
import dlib
from scipy.spatial import distance
from gtts import gTTS
import pygame
import os

app = Flask(__name__)

# 読み上げフラグ
alert_triggered = False

# 目のアスペクト比を計算する関数
def eye_aspect_ratio(eye):
    A = distance.euclidean(eye[1], eye[5])
    B = distance.euclidean(eye[2], eye[4])
    C = distance.euclidean(eye[0], eye[3])
    ear = (A + B) / (2.0 * C)
    return ear

# 閾値とフレームカウントの設定
EYE_AR_THRESH = 0.25
EYE_AR_CONSEC_FRAMES = 3

# Dlibの顔検出器とランドマーク予測器を初期化
detector = dlib.get_frontal_face_detector()
predictor = dlib.shape_predictor("shape_predictor_68_face_landmarks.dat")
(lStart, lEnd) = (42, 48)
(rStart, rEnd) = (36, 42)

# pygameの初期化
pygame.mixer.init()

def play_warning(name):
    # 音声メッセージを生成
    tts = gTTS(text=f"そこの寝ている{name}さん、起きなさい", lang='ja')
    tts.save("alert.mp3")  # 一時的に音声ファイルとして保存
    
    # 音声ファイルを再生
    pygame.mixer.music.load("alert.mp3")
    pygame.mixer.music.play()
    while pygame.mixer.music.get_busy():  # 再生中は待機
        continue

    # 一時ファイルを削除
    os.remove("alert.mp3")

@app.route("/", methods=["GET", "POST"])
def index():
    global alert_triggered
    alert_triggered = False  # ページをリロードするとフラグをリセット
    return render_template("index.html")

@app.route("/check_alert")
def check_alert():
    global alert_triggered
    return jsonify({"alert_triggered": alert_triggered})

def gen_frames(name):
    global alert_triggered
    cap = cv2.VideoCapture(0)
    cap.set(cv2.CAP_PROP_FPS, 15)  # フレームレートを15FPSに設定
    frame_counter = 0
    frame_skip_count = 2  # 2フレームごとに表示
    current_frame = 0

    while True:
        success, frame = cap.read()
        if not success or alert_triggered:  # 読み上げが終わったらカメラを停止
            break

        if current_frame % frame_skip_count == 0:
            gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
            faces = detector(gray, 0)

            for face in faces:
                shape = predictor(gray, face)
                shape = [(shape.part(i).x, shape.part(i).y) for i in range(68)]
                leftEye = shape[lStart:lEnd]
                rightEye = shape[rStart:rEnd]

                leftEAR = eye_aspect_ratio(leftEye)
                rightEAR = eye_aspect_ratio(rightEye)
                ear = (leftEAR + rightEAR) / 2.0
                print(ear)

                if ear < EYE_AR_THRESH:
                    frame_counter += 1
                    if frame_counter >= EYE_AR_CONSEC_FRAMES and not alert_triggered:
                        play_warning(name)  # gTTSで音声を再生
                        alert_triggered = True  # 読み上げ完了後にフラグをセット
                else:
                    frame_counter = 0

            # フレームサイズを縮小
            frame = cv2.resize(frame, (640, 480))
            ret, buffer = cv2.imencode('.jpg', frame)
            frame = buffer.tobytes()

            yield (b'--frame\r\n'
                   b'Content-Type: image/jpeg\r\n\r\n' + frame + b'\r\n')

        current_frame += 1

    cap.release()

@app.route('/video_feed')
def video_feed():
    name = request.args.get("name", "誰か")  # URLパラメータから名前を取得
    return Response(gen_frames(name), mimetype='multipart/x-mixed-replace; boundary=frame')

if __name__ == "__main__":
    app.run(debug=True, threaded=False)

```
</details>



<details>
<summary>index.htmlのコードまとめ</summary>

```index.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>スリープモニタリング</title>
    <style>
        /* 全体のレイアウトを中央寄せ */
        body {
            display: flex;
            justify-content: center;
            align-items: center;
            height: 100vh;
            margin: 0;
            font-family: Arial, sans-serif;
            background-color: #f3f4f6;
        }

        /* コンテナ全体のスタイル */
        .container {
            text-align: center;
            padding: 20px;
            background-color: #ffffff;
            border-radius: 10px;
            box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
            max-width: 400px;
            width: 100%;
        }

        /* ヘッダーとボタンのスタイル */
        h2 {
            color: #333;
            margin-bottom: 20px;
        }

        #nameForm label {
            font-weight: bold;
            color: #555;
        }

        #nameForm input[type="text"] {
            padding: 8px;
            margin: 10px 0;
            width: 80%;
            border: 1px solid #ddd;
            border-radius: 5px;
            text-align: center;
            font-size: 16px;
        }

        #nameForm button {
            padding: 10px 20px;
            background-color: #4CAF50;
            color: #fff;
            border: none;
            border-radius: 5px;
            font-size: 16px;
            cursor: pointer;
            transition: background-color 0.3s ease;
        }

        #nameForm button:hover {
            background-color: #45a049;
        }

        /* カメラ映像のスタイル */
        #videoStream {
            margin-top: 20px;
            width: 100%;
            height: auto;
            border-radius: 10px;
            box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
        }

        /* アラートメッセージのスタイル */
        #alertMessage {
            display: none;
            margin-top: 30px;
            color: #e74c3c;
            font-size: 2em;
            font-weight: bold;
            text-align: center;
            animation: blink 1s infinite;
        }

        /* アニメーション: 点滅効果 */
        @keyframes blink {
            0%, 100% { opacity: 1; }
            50% { opacity: 0; }
        }
    </style>
</head>
<body>
    <div class="container">
        <h2>スリープモニタリング</h2>
        <form id="nameForm">
            <label for="name">名前を入力してください</label>
            <input type="text" id="name" name="name" required>
            <button type="submit">開始</button>
        </form>

        <div id="alertMessage"><h1>起きろ！</h1></div>
        <img id="videoStream" alt="カメラ映像">
    </div>

    <script>
        document.getElementById('nameForm').onsubmit = function(event) {
            event.preventDefault();  // フォーム送信を防ぐ
            const name = document.getElementById('name').value;
            const videoStream = document.getElementById('videoStream');
            videoStream.src = `/video_feed?name=${encodeURIComponent(name)}`;
            videoStream.style.display = "block";  // 映像の表示
            checkAlert();
        }

        // 定期的にalert_triggeredの状態を確認
        function checkAlert() {
            fetch("/check_alert")
                .then(response => response.json())
                .then(data => {
                    if (data.alert_triggered) {
                        document.getElementById("alertMessage").style.display = "block";
                        document.getElementById("videoStream").style.display = "none";
                    } else {
                        setTimeout(checkAlert, 1000);  // 1秒ごとにチェック
                    }
                });
        }
    </script>
</body>
</html>

```

</details>


[shape_predictor_68_face_landmarks.datのリンク](https://github.com/davisking/dlib-models/blob/master/shape_predictor_68_face_landmarks.dat.bz2 "
shape_predictor_68_face_landmarks.datのリンク")

## app.py
リアルタイムで目の動きを監視し、閾値を下回ると音声で警告を発するシステムです。

<details>
<summary>コードの説明</summary>

### インポートと初期設定
FlaskでWebサーバーを構築し、OpenCVとdlibで画像処理を行い、gTTSとpygameで音声を再生するためのライブラリをインポートしています。
```python
from flask import Flask, render_template, request, Response, jsonify
import cv2
import dlib
from scipy.spatial import distance
from gtts import gTTS
import pygame
import os

app = Flask(__name__)
```

### 目のアスペクト比（EAR）の計算関数
目のランドマークの位置を使用して、目の開閉状態を示す「目のアスペクト比（EAR）」を計算します。この比率が低くなると、まばたきや目を閉じている状態を示します。
```python
def eye_aspect_ratio(eye):
    A = distance.euclidean(eye[1], eye[5])
    B = distance.euclidean(eye[2], eye[4])
    C = distance.euclidean(eye[0], eye[3])
    ear = (A + B) / (2.0 * C)
    return ear
```

### 閾値と顔検出の初期化
EARの閾値を設定し、目を閉じていると判定するフレーム数を指定します。
また、dlibの顔検出器とランドマーク予測器を初期化し、目のランドマークの範囲も設定します。
```python
EYE_AR_THRESH = 0.25
EYE_AR_CONSEC_FRAMES = 3
detector = dlib.get_frontal_face_detector()
predictor = dlib.shape_predictor("shape_predictor_68_face_landmarks.dat")
(lStart, lEnd) = (42, 48)
(rStart, rEnd) = (36, 42)
```

### 警告音声の生成と再生
gTTSでの音声生成と再生：指定の名前を含む日本語の警告メッセージを生成し、pygameで再生します。再生後は一時的に作成した音声ファイルを削除します。
```python
def play_warning(name):
    tts = gTTS(text=f"そこの寝ている{name}さん、起きなさい", lang='ja')
    tts.save("alert.mp3")
    pygame.mixer.music.load("alert.mp3")
    pygame.mixer.music.play()
    while pygame.mixer.music.get_busy():
        continue
    os.remove("alert.mp3")
```


### ルートとエンドポイントの設定
index.htmlを表示し、ページがリロードされたときに音声の再生フラグをリセットします。
```python
@app.route("/", methods=["GET", "POST"])
def index():
    global alert_triggered
    alert_triggered = False
    return render_template("index.html")
```

音声が再生されたかどうかの状態 (alert_triggered) をJSON形式で返します。
```python
@app.route("/check_alert")
def check_alert():
    global alert_triggered
    return jsonify({"alert_triggered": alert_triggered})
```

### フレーム生成と目の監視
指定したカメラからフレームを取得し、リアルタイムで目のEARを計算します。EARが閾値以下で連続した場合にplay_warningを呼び出し、警告音声を再生します。
```python
def gen_frames(name):
    global alert_triggered
    cap = cv2.VideoCapture(0)
    cap.set(cv2.CAP_PROP_FPS, 15)
    frame_counter = 0
    frame_skip_count = 2
    current_frame = 0
    # ループ内の目の状態監視...
```


### 動画ストリームエンドポイント
gen_framesを呼び出して、指定された名前で動画ストリームを生成し、クライアントにリアルタイムで送信します。
```python
@app.route('/video_feed')
def video_feed():
    name = request.args.get("name", "誰か")
    return Response(gen_frames(name), mimetype='multipart/x-mixed-replace; boundary=frame')
```


### アプリ起動
Flaskアプリをデバッグモードで起動します。
```python
if __name__ == "__main__":
    app.run(debug=True, threaded=False)
```

</details>


## index.html
app.pyで設定されたカメラ映像を表示し、アラートを確認し表示する仕組みになっています。

<details>
<summary>コードの説明</summary>

### HTMLの基本設定とCSSスタイル
```html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>スリープモニタリング</title>
    <style>
        /* スタイル定義 */
    </style>
</head>
```

### コンテナ要素
* nameFormで名前を入力し、モニタリングを開始するためのボタンが表示されています。
* alertMessageはアラートが発動されたときに、「起きろ！」のメッセージを点滅させるように設定されています。
* videoStreamというIDの画像要素を使用して、サーバーから受信するカメラ映像を表示します。
```html
<body>
    <div class="container">
        <h2>スリープモニタリング</h2>
        <form id="nameForm">
            <label for="name">名前を入力してください</label>
            <input type="text" id="name" name="name" required>
            <button type="submit">開始</button>
        </form>

        <div id="alertMessage"><h1>起きろ！</h1></div>
        <img id="videoStream" alt="カメラ映像">
    </div>
</body>
```

### JavaScriptの機能
* onsubmitイベントでデフォルトのフォーム送信を無効にし、名前を取得してvideo_feedエンドポイントを呼び出します。その後取得した映像をvideoStream要素に表示します。
* encodeURIComponent(name)で、特殊文字が含まれていてもURLとして扱えるようにエンコードしています。
```html
<script>
    document.getElementById('nameForm').onsubmit = function(event) {
        event.preventDefault();  // フォーム送信を防ぐ
        const name = document.getElementById('name').value;
        const videoStream = document.getElementById('videoStream');
        videoStream.src = `/video_feed?name=${encodeURIComponent(name)}`;
        videoStream.style.display = "block";  // 映像の表示
        checkAlert();
    }
```

### アラートチェック機能
* エンドポイントに定期的にリクエストを送信し、alert_triggeredの状態をチェックします。
* alert_triggeredがtrueの場合、アラートメッセージを表示し、カメラ映像を非表示にします。アラートが発生していない場合は、1秒後に再度checkAlertを呼び出し、確認を繰り返します。
```javascript
function checkAlert() {
    fetch("/check_alert")
        .then(response => response.json())
        .then(data => {
            if (data.alert_triggered) {
                document.getElementById("alertMessage").style.display = "block";
                document.getElementById("videoStream").style.display = "none";
            } else {
                setTimeout(checkAlert, 1000);  // 1秒ごとにチェック
            }
        });
}
```
</details>

## まとめ
作成してみたものの、正直これでは起きないなと思いました笑
特に音声は機械音なので、もう少し人間の声に近づけることができれば、全然違う感覚なのではないかと思います。