OpenCVで猫背を検知する

# はじめに

デスクワークで作業に集中してると気づかぬうちに猫背になってしまうことありますよね。
リモートワークだと他人の目がないので尚更です。

そこで姿勢が悪くなるとアラートを出す仕組みを作ってみました！

## 環境
Python 3.7.4
Webカメラ（Logicool HD Webcam C615）

# どう実現するか
Webカメラに映る目の高さがある程度下がれば猫背と判定するようにします。


# OpenCV

インストール

```
$ pip install opencv-python
```


<br>


まず、カメラ映像をキャプチャして目の検出をしていきます。

OpenCVでの物体検出の方法は以下が参考になります。
>「Haar Cascadesを使った顔検出」
>http://labs.eecs.tottori-u.ac.jp/sd/Member/oyamada/OpenCV/html/py_tutorials/py_objdetect/py_face_detection/py_face_detection.html#face-detection

今回は目を検出したいので、分類器に「haarcascade_eye.xml」を使います。以下リンクよりダウンロードできます。
>https://github.com/opencv/opencv/tree/master/data/haarcascades

```python:capture.py
import numpy as np
import cv2

# カメラのデバイス番号
# 使用できるカメラが 0 から順番付けされている
DEVICE_ID = 0

# 分類器を選択
cascade = cv2.CascadeClassifier('haarcascade_eye.xml')

# カメラ映像をキャプチャ
cap = cv2.VideoCapture(DEVICE_ID, cv2.CAP_DSHOW)

while cap.isOpened():
    # フレームの取得
    ret, frame = cap.read()

    # グレースケールに変換
    img_gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)

    # 目を検出
    eyes = cascade.detectMultiScale(img_gray, minSize=(30, 30))

    # 検出した目を四角で囲む
    for (x, y, w, h) in eyes:
        color = (255, 0, 0)
        cv2.rectangle(img_gray, (x, y), (x+w, y+h), color, thickness=3)

    # 表示
    cv2.imshow('capture', img_gray)

    # ESCキーでループを抜ける
    if cv2.waitKey(1) & 0xFF == 27:
        break

# 終了処理
cv2.destroyAllWindows()
cap.release()
```

25行目の `y` が検出された目の高さの情報です。
したがって、この値を記録してその落ち具合を見ていけばOK！




# 移動平均

さて、目の高さが取得できましたが、この値をそのまま用いてしまってはあまり良くありません。
ほんの一瞬下を向いただけで猫背と判定されてしまってはたまったものではないので、そのために一定時間の平均値を扱うことにします。

そこで登場するのが「移動平均」です。株やFXをする方は馴染みがあるかもしれません。
今回は移動平均の中でも最も簡単な「__単純移動平均__」を使いました。


>単純移動平均 (英: Simple Moving Average; SMA) は、直近の n 個のデータの重み付けのない単純な平均である。例えば、10日間の終値の単純移動平均とは、直近の10日間の終値の平均である。それら終値を${\displaystyle p_{M}}p_{{M}}, {\displaystyle p_{M-1}}p_{{M-1}}, ..., {\displaystyle p_{M-9}}p_{{M-9}}$ とすると、単純移動平均 SMA(p,10) を求める式は次のようになる:

>```math
{\text{SMA}}_{M}={p_{M}+p_{M-1}+\cdots +p_{M-9} \over 10}
```

>翌日の単純移動平均を求めるには、新たな終値を加え、一番古い終値を除けばよい。つまり、この計算では、改めて総和を求め直す必要はない。

>```math
{\text{SMA}}_{{\mathrm  {today}}}={\text{SMA}}_{{\mathrm  {yesterday}}}-{p_{{M-n+1}} \over n}+{p_{{M+1}} \over n}
```

※ wikipedia引用


要は、データの各要素に対してそれの過去 n 個分を平均化すれば良いわけです。

この単純移動平均の時系列データの最初と最後の差がある閾値より大きくなれば猫背であると判定できます。


# 投影変換

今まで話してきた目の高さ情報`y`というのはカメラ映像のピクセルの位置情報でしかありません。
なので、`y`の値の変化が現実世界の何cmに相当するのかというのは別途計算しないといけません。

そこで投影変換というものを考えてやります。
カメラの映像というのは2次元なので、映像化する際は3次元の世界を2次元の平面に投影する必要があります。
今回は、透視投影変換として考えます。

透視投影変換は3次元の座標 $(x, y, z)$ に対して $\left(\frac{x}{z}, \frac{y}{z}, 0\right)$ のように変換します。
「遠いものは小さく見える」という直観と合致するので分かりやすいかと思います。

したがって、カメラ映像の高さの差 $\Delta y_d$ (px)と現実世界の高さの差 $\Delta y_v$ (cm)との関係は以下のようになります。

```math
\begin{equation}
  \frac{\Delta y_d}{f} = \frac{\Delta y_v}{z_v} \tag{1}
\end{equation}
```

$z_v$ はカメラと対象物との距離、$f$ はカメラの焦点距離です。
三角形の相似を考えれば簡単です。

![diagram-20200929_2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/328099/c3956fba-cb46-86d8-8f5d-f4cb872f9245.png)

カメラの焦点距離はカメラの機種等によりまちまちなので、キャリブレーションによって取得する必要があります。
カメラのキャリブレーションもOpenCVによって提供されています。

>「カメラ・キャリブレーション」
>http://whitewell.sakura.ne.jp/OpenCV/py_tutorials/py_calib3d/py_calibration/py_calibration.html

キャリブレーションによって得たカメラの内部パラメータの中身は以下のようになっているので、ここから焦点距離が分かります。


```math
K = \left[
      \begin{array}{ccc}
        f & 0 & x_c \\
        0 & f & y_c \\
        0 & 0 & 1
      \end{array}
    \right]
```
$x_c, y_c$ は投影面の中心点です。
<br>
ただこのキャリブレーション、実際にカメラでチェスボードを撮影した画像を用意しないといけないので少々面倒。。。
私が使っているWebカメラ（Logicool HD Webcam C615）だと $f$ が大体500ぐらいでしたので参考までに。

# 猫背の判定

$f$ が取得できてしまえばもう終わりです。

目の高さの単純移動平均の時系列データで先頭と末尾の差を $\Delta y_d$ として、(1)式で判定してやればいいだけです。
カメラからパソコンまでの距離 $z_v$ は大体 45cm ぐらい。また、現実世界の目の高さの差の閾値 $\Delta y_v$ は 3cm にしました。



# 全体のコード


データの可視化のために pyplot でプロットしています。

また、アラートは Tkinter の messagebox を使っています。


```python:detect_posture.py
import numpy as np
from matplotlib import pyplot as plt
import cv2
import tkinter as tk
from tkinter import messagebox


WINDOW_NAME = "capture"     # Videcapute window name
CAP_FRAME_WIDTH = 640       # Videocapture width
CAP_FRAME_HEIGHT = 480      # Videocapture height
CAP_FRAME_FPS = 30          # Videocapture fps (depends on user camera)

DEVICE_ID = 0               # Web camera id

SMA_SEC = 10                        # SMA seconds
SMA_N = SMA_SEC * CAP_FRAME_FPS     # SMA n

PLOT_NUM = 20                   # Plot points number
PLOT_DELTA = 1/CAP_FRAME_FPS    # Step of X axis

Z = 45                  # (cm) Distance from PC to face 
D = 3                   # (cm) Limit of lowering eyes
F = 500                 # Focal length


def simple_moving_average(n, data):
    """ Return simple moving average """
    result = []
    for m in range(n-1, len(data)):
        total = sum([data[m-i] for i in range(n)])
        result.append(total/n)
    return result

def add_simple_moving_average(smas, n, data):
    """ Add simple moving average """
    total = sum([data[-1-i] for i in range(n)])
    smas.append(total/n)


if __name__ == '__main__':
    # Not show tkinter window
    root = tk.Tk()
    root.iconify()

    # Chose cascade
    cascade = cv2.CascadeClassifier("haarcascade_eye.xml")

    # Capture setup
    cap = cv2.VideoCapture(DEVICE_ID, cv2.CAP_DSHOW)
    cap.set(cv2.CAP_PROP_FRAME_WIDTH, CAP_FRAME_WIDTH)
    cap.set(cv2.CAP_PROP_FRAME_HEIGHT, CAP_FRAME_HEIGHT)
    cap.set(cv2.CAP_PROP_FPS, CAP_FRAME_FPS)

    # Prepare windows
    cv2.namedWindow(WINDOW_NAME)

    # Time series data of eye height
    eye_heights = []
    sma_eye_heights = []

    # Plot setup
    ax = plt.subplot()
    graph_x = np.arange(0, PLOT_NUM*PLOT_DELTA, PLOT_DELTA)
    eye_y = [0] * PLOT_NUM
    sma_eye_y = [0] * PLOT_NUM
    eye_lines, = ax.plot(graph_x, eye_y, label="realtime")
    sma_eye_lines, = ax.plot(graph_x, sma_eye_y, label="SMA")
    ax.legend()

    while cap.isOpened():
        # Get a frame
        ret, frame = cap.read()

        # Convert image to gray scale
        img_gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
        
        # Detect human eyes
        eyes = cascade.detectMultiScale(img_gray, minSize=(30, 30))

        # Mark on the detected eyes
        for (x, y, w, h) in eyes:
            color = (255, 0, 0)
            cv2.rectangle(img_gray, (x, y), (x+w, y+h), color, thickness=3)
        
        # Store eye heights
        if len(eyes) > 0:
            eye_average_height = CAP_FRAME_HEIGHT - sum([y for _, y, _, _ in eyes]) / len(eyes)
            eye_heights.append(eye_average_height)

            if len(eye_heights) == SMA_N:
                sma_eye_heights = simple_moving_average(SMA_N, eye_heights)
            elif len(eye_heights) > SMA_N:
                add_simple_moving_average(sma_eye_heights, SMA_N, eye_heights)
            

        # Detect bad posture
        if sma_eye_heights and (sma_eye_heights[0] - sma_eye_heights[-1] > F * D / Z):
            res = messagebox.showinfo("BAD POSTURE!", "Sit up straight!\nCorrect your posture, then click ok.")
            if res == "ok":
                # Initialize state, and restart from begening
                eye_heights = []
                sma_eye_heights = []
                graph_x = np.arange(0, PLOT_NUM*PLOT_DELTA, PLOT_DELTA)
                continue

        # Plot eye heights
        graph_x += PLOT_DELTA
        ax.set_xlim((graph_x.min(), graph_x.max()))
        ax.set_ylim(0, CAP_FRAME_HEIGHT)

        if len(eye_heights) >= PLOT_NUM:
            eye_y = eye_heights[-PLOT_NUM:]
            eye_lines.set_data(graph_x, eye_y)
            plt.pause(.001)
        
        if len(sma_eye_heights) >= PLOT_NUM:
            sma_eye_y = sma_eye_heights[-PLOT_NUM:]
            sma_eye_lines.set_data(graph_x, sma_eye_y)
            plt.pause(.001)

        
        # Show result
        cv2.imshow(WINDOW_NAME, img_gray)

        # Quit with ESC Key
        if cv2.waitKey(1) & 0xFF == 27:
            break
    
    # End processing
    cv2.destroyAllWindows()
    cap.release()

```


# 参考

https://cvml-expertguide.net/2019/08/17/term-camera-model/