sey323’s blog

すーぱーえんじにあ

TensorflowHubの骨格検出AIでジェスチャー検出し家電を操作する

概要

家にスマートリモコンのNatureRemoを導入し、Alexaと連携して音声で家電を操作している。連携により「アレクサ!電気消して」というだけで電気を消してくれるので、電気を付けようとたらリモコンがなくて部屋を探し回る、みたいな手間がなくなり非常に助かっている。

しかし我が家は現在二人暮らしで、かつ最近はお互いが在宅勤務ということもあり、片方が会議の時は音声で家電を操作することができない。なので結局リモコンでの電気の操作を強いられることが多々あり、リモコンを手放すことができず面倒だなと感じていた。そこで音声に変わる第3のインタフェースとして、ジェスチャーで家電を操作を導入し「脱リモコン」を目指す。

出来上がったものはこんな感じ

実装

処理の全体像は以下の通り。カメラに映った人の骨格を検出し、抽出した骨格情報から指定したジェスチャーが行われているかを判断する。そこでジェスチャーが検知されたら、そのジェスチャーに紐づく操作を家電に対して行う、というものである。

処理の全体像

ソースコードの全体像は、下記のリポジトリを参照。

github.com

1. 骨格検出

はじめに、ジェスチャーの検出に必要な骨格検出を行う。入力を画像とし、その画像に映る人物の骨格を抽出するAIプログラムを作成する。利用するAIは、TensorflowHubで公開されている学習済みモデルを利用する。

qiita.com

TensorflowHubに公開されているモデルの中から、Googleが提供しているものでかつ最もダウンロード数が多いmovenet/singlepose/lightningのモデルを利用する。このモデルは、1人の骨格の検出しかできないが、モデルサイズが9.34MBと非常に軽いため、ラズパイのようなスペックの低いPCでも、リアルタイムに近い検出ができる。

tfhub.dev

上記のモデルを利用した、骨格検出のプログラムの全体像を以下に示す。

import collections
import logging
from collections import deque

import numpy as np
import tensorflow as tf
import tensorflow_hub as hub


logging.basicConfig(format="%(levelname)s:%(message)s", level=logging.INFO)
logger = logging.getLogger(__name__)


class PoseEstimator:
    """入力画像から骨格のキーポイントを返す。"""

    def __init__(self) -> None:
        # Download the model from TF Hub.
        model = hub.load("https://tfhub.dev/google/movenet/singlepose/lightning/4")
        self.movenet = model.signatures["serving_default"]

    def predict(self, target_image: np.ndarray) -> np.ndarray:
        """RGB画像の入力から、その画像に映る1人の骨格のキーポイントを返す。

        Args:
            target_image (np.ndarray): 処理対象の画像

        Returns:
            np.ndarray: 検出されたキーポイント
        """
        # 推論できるように画像の整形
        image = tf.expand_dims(target_image, axis=0)
        image = tf.cast(tf.image.resize_with_pad(image, 192, 192), dtype=tf.int32)
        # Run model inference.
        outputs = self.movenet(image)
        # Output is a [1, 1, 17, 3] tensor.
        keypoints = outputs["output_0"]

        del outputs, image, target_image
        return keypoints.numpy()

    def draw_prediction_on_image(self, target_image: np.ndarray, keypoints: np.ndarray):

        from util import draw_prediction_on_image

        return draw_prediction_on_image(target_image, keypoints)



if __name__ == "__main__":
    import argparse

    import cv2

    # 引数の設定
    parser = argparse.ArgumentParser()

    parser.add_argument("image_path", help="実験対象の画像へのパス")

    args = parser.parse_args()

    img = cv2.imread(args.image_path)

    # モデルの初期化
    pe = PoseEstimator()
    # 画像のキーポイントを取得
    keypoints = pe.predict(img)
    print(keypoints)

    # 実行結果を保存
    drwaed_img = pe.draw_prediction_on_image(img, keypoints=keypoints)
    cv2.imwrite(f"{args.image_path.split('.')[0]}_results.png", drwaed_img)

    # 出力
    h, w, _ = drwaed_img.shape
    concat_img = cv2.hconcat([cv2.resize(img, (w, h)), drwaed_img])
    cv2.imshow("smaple", concat_img)

    # キーが押されるまで待ち続ける。
    cv2.waitKey(0)
    cv2.destroyAllWindows()

動作確認として、サンプル画像のパスを引数に指定し、下記のコマンドで実行する。

$ python src/model.py sample/sample_1.jpg 
INFO:Using /var/folders/1c/tkr84d1j6ql32nmyqsp4qgth0000gn/T/tfhub_modules to cache modules.
[[[[0.35269305 0.56018114 0.34519652] # 鼻
   [0.34652418 0.569185   0.5843401 ] # 左目
   [0.33905435 0.5551213  0.49364823] # 右目
   [0.33489165 0.56405437 0.21565473] # 左耳
   [0.31956142 0.52063155 0.6094666 ] # 右耳
   [0.37548536 0.5601141  0.56941295] # 左肩
   [0.34683752 0.4423415  0.43283918] # 右肩
   [0.3764574  0.6109186  0.4019506 ] # 左肘
   [0.2845107  0.3561053  0.62603873] # 右肘
   [0.3453905  0.6700973  0.4374153 ] # 左手首
   [0.19540781 0.2809697  0.21285069] # 右手首
   [0.5447822  0.51521325 0.55029094] # 左尻
   [0.54367995 0.45068154 0.6387472 ] # 右尻
   [0.5232058  0.63429034 0.57733154] # 左膝
   [0.57336956 0.45017916 0.4271448 ] # 右膝
   [0.69182956 0.7116632  0.39275706] # 左足首
   [0.75386184 0.44888923 0.65232956] # 右足首
]]]

モデルから得られた出力に、各配列がそれぞれどの関節に当たるかコメントを記載した。関節ごとに[0-1]の範囲に正規化された値が、[ y座標, x座標, 信頼スコア]の順に格納されている。*1

プログラムの実行により得られた画像を以下に示す。前処理として入力画像を192x192に圧縮をしている影響で、元画像に描画したときの関節の位置に少しズレが生じている。

左が入力画像で、右が検出された骨格の位置を元画像にマッピングした画像

しかし関節の位置が大きく外れているというわけではなく、ジェスチャーの判定に影響は少なそうなので、このモデルで抽出したキーポイントの情報を利用しジェスチャーの検出を行う。

2. ジェスチャー検出機の作成

ジェスチャーの定義

キーポイント状態から、その人が行っているジェスチャーの種類を確定させる。先程の実行例より各関節の値が[ y座標, x座標, 信頼スコア]の順に格納されていることが確認できたので、この情報をもとにルールベースでジェスチャーの定義を行う。

下記は、上の章の出力例の再掲である。

$ python src/model.py sample/sample_1.jpg 
INFO:Using /var/folders/1c/tkr84d1j6ql32nmyqsp4qgth0000gn/T/tfhub_modules to cache modules.
[[[[0.35269305 0.56018114 0.34519652] # 鼻
   [0.34652418 0.569185   0.5843401 ] # 左目
   [0.33905435 0.5551213  0.49364823] # 右目
   [0.33489165 0.56405437 0.21565473] # 左耳
   [0.31956142 0.52063155 0.6094666 ] # 右耳
   [0.37548536 0.5601141  0.56941295] # 左肩
   [0.34683752 0.4423415  0.43283918] # 右肩
   [0.3764574  0.6109186  0.4019506 ] # 左肘
   [0.2845107  0.3561053  0.62603873] # 右肘
   [0.3453905  0.6700973  0.4374153 ] # 左手首
   [0.19540781 0.2809697  0.21285069] # 右手首
   [0.5447822  0.51521325 0.55029094] # 左尻
   [0.54367995 0.45068154 0.6387472 ] # 右尻
   [0.5232058  0.63429034 0.57733154] # 左膝
   [0.57336956 0.45017916 0.4271448 ] # 右膝
   [0.69182956 0.7116632  0.39275706] # 左足首
   [0.75386184 0.44888923 0.65232956] # 右足首
]]]

例えば「左手を垂直に上げた時」のジェスチャーを「左手首を鼻の位置より上、かつ鼻の位置より左にある場合」と定義することで、下記のようにジェスチャーのルールを作成できる。

モデルから得られる画像のxy座標は、画像の左上を起点とした値となるので、数値が0に近いほど、実世界では高い座標にあることを示している。なので例えば、「左手首が鼻の位置より高い状態」を表現するときは、プログラム的には鼻のy座標 > 左手首のy座標となる。ここら辺は直感的ではないのでジェスチャーを自身で追加する場合は注意が必要となる。

~~~省略~~~

    def _left_wrist_up_cross(self, keypoint: np.ndarray) -> bool:
        """左手を上げている状態

        Args:
            keypoint (np.ndarray): AIで検出された各キーポイントの配列

        Returns:
            bool: 左手を上げている状態であると判別された時: True
        """
        nose_height = keypoint[0][0] # 鼻のy座標
        nose_width = keypoint[0][1] # 鼻のx座標
        nose_score = keypoint[0][2] # 鼻の信頼スコア
        left_wrist_height = keypoint[9][0] # 左手首のy座標
        left_wrist_width = keypoint[9][1] # 左手首のx座標
        left_wrist_score = keypoint[9][2]  # 左手首の信頼スコア
        if nose_score < self.threshold and left_wrist_score < self.threshold:
            return False
        if nose_height > left_wrist_height and nose_width < left_wrist_width:
            logger.debug("左手を上げましました。")
            return True
        else:
            return False

~~~省略~~~

また検出された関節の信頼スコアに閾値を設定することで、信頼スコアが低い場合はジェスチャー検出を行わないようにしている。

キューによるジェスチャー判定

今回は、カメラから取得した連続した画像を利用してジェスチャーの判定をすることを想定しているため、画像1回の検出でジェスチャーを確定するのではなく、何フレーム間で同じジェスチャーが行われた場合に、そのジェスチャーを行っていると判定する。

そのために、10フレーム分のジェスチャーの結果を保存するキューを作成し、そのキューにたまったジェスチャーの最頻値を、行われたジェスチャーと返すように処理を作成した。

~~~省略~~~

class GestureDetector:
    """キーポイントからジェスチャーを返す。"""

    def __init__(self, maxlen: int = 10, threshold: float = 0.2) -> None:
        self.queue = deque(maxlen=maxlen)
        self.threshold = threshold

    def check_gesture(self, keypoint: np.ndarray) -> Gesture:
        """キーポイントから該当するジェスチャーの種類を推定する。

        Args:
            keypoint (np.ndarray): 1人の人物の関節の座標

        Returns:
            Gesture: ジェスチャーの種類の列挙型(enums.pyを参照)
        """
        if self._left_wrist_up_cross(keypoint):  # 左手を上げている状態
            self.queue.append(Gesture.LEFT_WRIST_UP)
        else:  # 何も検出されなかったときはGestrueなし
            logger.debug("ジェスチャーなし")
            self.queue.append(Gesture.NO_GESTURE)
        return self._check_mod_gesture()

    def _check_mod_gesture(
        self,
    ) -> Gesture:
        """検出されたジェスチャーのキューから、最頻値のジェスチャーを返す。

        Returns:
            Gesture: ジェスチャーの種類
        """
        if len(self.queue) < self.queue.maxlen:
            # キューが満たされていないときは、ジェスチャーなしとする。
            return Gesture.NO_GESTURE
        counter = collections.Counter(self.queue)
        mod_gesture, mod_gesture_count = counter.most_common()[0]
        if mod_gesture_count < int(self.queue.maxlen * 0.8):
            # 最頻値のジェスチャーがキューサイズの80%以下の場合は、ジェスチャーなしとする。
            return Gesture.NO_GESTURE

        if mod_gesture != Gesture.NO_GESTURE:  # ジェスチャーが検出された場合は、キューをリセットする。
            logger.debug("ジェスチャーが検出されたので、キューをリセットします。")
            self.queue.clear()
        return mod_gesture

結果として、この機能により、ジェスチャーの誤検知を大幅に防ぐことができた。キューのサイズはGestureDetectormaxlenで指定できるが、キューのサイズがどれくらいかが適切かは、利用するカメラやPCの性能・環境に依存するので、環境に応じて変更する必要がある。

3. 家電の操作

家電の操作は、NatureRemoのAPIを利用する。はじめに、以下のURLにアクセスし、自宅のNatureRemoのAPI キーを発行する。

その後、操作したい家電に対してリクエストを行うプログラムを記載していく。下記の例は、リビングの電球を点灯させるためにsend_living_room_light()を実装した場合の例である。

import argparse

import requests


class NatureRemo:
    def __init__(self, api_token) -> None:
        self.api_token = api_token

    def send_living_room_light(self) -> None:
        """リビングの電気をつける
        Args:
            api_key (str): NatureRemoから取得するAPIToken
        """
        headers = {
            "accept": "application/json",
            "Content-Type": "application/x-www-form-urlencoded",
            "Authorization": "Bearer " + self.api_token,
        }
        # ここは操作したい家電に応じて異なる。
        response = requests.post(
            "https://api.nature.global/1/appliances/${自宅にあるNatureRemoに登録した電球のID}/light",
            headers=headers,
            data="button=on",
        )
        return response.json()


if __name__ == "__main__":
    import argparse

    # 引数の設定
    parser = argparse.ArgumentParser()

    parser.add_argument("api_token", help="NatureRemoから取得したAPIキー")

    args = parser.parse_args()

    remo = NatureRemo(api_token=args.api_token)

    print(remo.send_living_room_light())

デバッグ実行を下記のコマンドで行う。

python src/appliances.py ${NatureRemoから取得したAPIトークン}

実行後指定した家電を操作できればOK。自分の家のNatureRemoに連携した家電を操作したい場合は、NatureRemoが提供する下記のAPIのドキュメントから、自宅の家電に合わせた処理を記載する必要がある。

swagger.nature.global

4. 実行

1,2,3で作成した各要素を組み合わせ、検出したジェスチャーによって家電の操作を行うプログラムを作成する。先程定義した左手を上げる動作とリビングの電気を点灯させる動作を組み合わせ、左手を上げて電気を点灯させられるようにする。

import argparse
import logging
import time

import cv2

from appliances import NatureRemo
from enums import Gesture
from model import GestureDetector, PoseEstimator

logging.basicConfig(format="%(levelname)s:%(message)s", level=logging.DEBUG)
logger = logging.getLogger(__name__)


def call_apliance(gesture: Gesture, appliance: NatureRemo) -> None:
    """検出されたジェスチャーの種類によって任意の家電を操作する。

    Args:
        gesture (Gesture): 検出されたジェスチャー
        appliance (NatureRemo): 操作する家電のコントローラークラス
    """
    if gesture == Gesture.NO_GESTURE:
        return 0

    if gesture == Gesture.LEFT_WRIST_UP:  # 左手を上げている状態
        logger.info("リビングの電気を点灯させます")
        appliance.send_living_room_light()

    # 家電を操作した後は3秒停止する。
    time.sleep(3)


def main(api_token: str, video_num: int, show_window: bool) -> None:
    pe = PoseEstimator()  # 関節の検出に利用するモデルを選択する。
    gd = GestureDetector(threshold=0.5)  # ジェスチャーを検出する。
    remo = NatureRemo(api_token=api_token)  # 操作する製品

    # USBカメラ
    cap = cv2.VideoCapture(video_num)
    cap.set(cv2.CAP_PROP_FPS, 30)
    cap.set(cv2.CAP_PROP_FRAME_WIDTH, 256)
    cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 256)
    logger.info("ビデオを有効化しアプリケーションを開始します。")

    while True:
        _, img = cap.read()

        # keypointを取得
        keypoints = pe.predict(img)

        # keypointからジェスチャーの内容を取得する。
        gesture = gd.check_gesture(keypoint=keypoints[0][0])
        call_apliance(gesture, appliance=remo)

        # 可視化する。
        if show_window:
            cv2.imshow("Video", pe.draw_prediction_on_image(img, keypoints=keypoints))

            # qを押すと止まる。
            if cv2.waitKey(1) & 0xFF == ord("q"):
                break


if __name__ == "__main__":
    # 引数の設定
    parser = argparse.ArgumentParser()

    parser.add_argument("api_token", help="NatureRemoから取得したAPIキー")
    parser.add_argument(
        "--video_num",
        default=0,
        help="利用するカメラモジュールの番号。ls /dev/video*で対象のビデオの番号を取得し、引数に指定する。default=0",
    )
    parser.add_argument(
        "--show", help="実行時にカメラモジュールの映像を画面に出力する場合は、こちらのオプションを付与する。", action="store_true"
    )
    args = parser.parse_args()

    main(api_token=args.api_token, video_num=args.video_num, show_window=args.show)

NatureRemoのAPIキーを引数とし下記のコマンドで実行する。

python src/main.py ${APIキー}

再掲にはなるが、上記を実行することでアプリケーションが立ち上がり、左手を上げて電気を点灯させることができた。

おわりに

TensorflowHubの学習済みAIモデルを利用することで、簡単に骨格検出ができて驚いた。家電操作の精度に関しても、環境に合わせてキューのフレーム数や関節の信頼スコアの閾値を調整することで、普段の利用では気にならないレベルの精度で家電を操作することができた。

深夜に謎に電気が点灯するバグ?があったので夜は停止した方がいいです。

*1:詳細は公式のドキュメントを参照: https://tfhub.dev/google/movenet/singlepose/lightning/4