NTTドコモR&Dの技術ブログです。

筋トレをサポートするAIトレーナーを作ってみた

はじめに

こんにちは。 @dcm_haruyama です。

この記事は、NTTドコモ R&D Advent Calendar 2023 24日目の記事です。 良いクリスマスイブをお過ごしでしょうか。 今日のテーマは筋トレということで、年末年始の運動不足解消にピッタリですね!

私のアドベントカレンダーは3年目となりまして、過去記事も載せておきますので是非ご覧ください。

↓↓2021年↓↓ qiita.com ↓↓2022年↓↓ nttdocomo-developers.jp

こう見るとスポーツネタが多いですが、筆者は普段の業務では、スポーツAI解析や自動運転の遠隔管制システムの技術領域を担当しており、人物の動作認識を扱うことが多いです。

今回は、その動作認識の1例として、筋トレをしている人物の動きを解析して、フォームチェックやカウントを行なってくれるAIトレーナーを作ってみようと思います。

本記事の対象となる人

  • 筋トレに興味がある人
  • リアルタイム動画像認識に興味がある人
  • 人物の動作認識に興味がある人

がご覧になるとより楽しめる記事となっております。 それでは、早速、AIトレーナーの実装について説明していきます!

1. 筋トレAIトレーナーを作ろう

今回対象にする筋トレは、王道の「スクワット」です。スクワットは誰もができる簡単な自重トレーニング方法です。

ただ、簡単だからこそ、フォームを間違えてしまいやすく、効果が低減しがちです。 かく言う私も筋トレ超初心者ですが、つい脚を開きすぎたり腰をしっかり落としきれず、間違ったフォームでも、30回やったことにするか・・・みたいなことを良くしてしまいます。今回は、そんな甘えは許さん!と言ってくれるようなAIトレーナーを作っていきます。

ここで、AIトレーナーに見てもらう項目(機能)を列挙します。

  • 肩と踵の位置が揃っているか
  • 腰がちゃんと落ちているか
  • スクワット回数カウント
  • カウントリセット

今回はこれらをカメラを使ったリアルタイムAI解析で実現していきます。 次の章では、そのアーキテクチャを説明します。

2. AIトレーナーのアーキテクチャ

上記の図に示したように、カメラ入力をリアルタイムに解析し、人物の骨格情報を抽出して計算処理を行うことでAIトレーナーを実現します。 AIトレーナーのコアとなるAI解析の部分にはDeepStream SDKを使います。DeepStream SDKの詳細はこちらの記事[1]で説明されていますので、ここではざっくりの説明とします。

DeepStream SDKは、GStreamerを基盤としてAIベースのマルチセンサー処理やビデオ、音声、画像の理解を実現するストリーミング分析ツールキットです。開発者はストリーム処理パイプラインを作成し、ニューラルネットワークなどの複雑な処理タスク (トラッキング、ビデオのエンコード/デコード、ビデオのレンダリングなど) を組み込めるようになります。DeepStreamのパイプラインにより、ビデオ、画像、センサーのデータをリアルタイムで分析することができます。

2.1 DeepStream SDKのPythonバインディング

DeepStream SDKは、GStreamerフレームワークのPythonバインディングであるGst Pythonを使うことができます[2]。 Gst Pythonでの記述ができると、Pythonに慣れている開発者のアクセシビリティが高まります。

NVIDIAから公式のサンプルアプリ[3]が提供されていますので、こちらに倣ってビルドをすることで、DeepStreamにより解析された結果(Metadata)をpython(OpenCVなど)で自由に扱うことができるようになります。

次の章では、具体的にどのようなMetadataをAIトレーナーで扱うかについて説明します。

2.2 AIトレーナーで扱うMetadata

DeepStream SDKにおけるMetadataの扱い方については、公式ページを参考にすることができます。 ここでは、以下のコードのように、フレーム情報→検出結果→オブジェクトの情報→姿勢推定結果という順番で解析情報の取得を行なっていきます。

# pydsのインポート
import pyds

def run(self, gst_buffer_addr):
    # フレーム情報をバッファから取得
    image_buffer = pyds.get_nvds_buf_surface(gst_buffer_addr, 0)

    # 検出結果をバッファから取得
    batch_meta = pyds.gst_buffer_get_nvds_batch_meta(gst_buffer_addr)
    l_frame = batch_meta.frame_meta_list

    while l_frame is not None:
        try:
            frame_meta = pyds.NvDsFrameMeta.cast(l_frame.data)
        except StopIteration:
            break

        # 検出したオブジェクトの情報を取得
        l_obj = frame_meta.obj_meta_list
        while l_obj is not None:
            try:
                obj_meta = pyds.NvDsObjectMeta.cast(l_obj.data)
            except StopIteration:
                break

                l_obj_user = obj_meta.obj_user_meta_list
                user_meta = pyds.NvDsUserMeta.cast(l_obj_user.data)
                msg_meta = pyds.NvDsEventMsgMeta.cast(user_meta.user_meta_data)
                obj = pyds.NvDsPoseEstimationObject.cast(msg_meta.extMsg)
               
                # 推定された骨格情報を取得
                l_keypoints = obj.keypoints
                while l_keypoints is not None:
                    try:
                        keypoints = pyds.NvDsKeypointObject.cast(l_keypoints.data)
                    except StopIteration:
                        break

DeepStreamにより取得される骨格点は以下のようになります。

Metadata 骨格点
nose
neck
eyeLeft 左目
eyeRight 右目
earLeft 左耳
earRight 右耳
shoulderLeft 左肩
shoulderRight 右肩
elbowLeft 左肘
elbowRight 右肘
wristLeft 左手首
wristRight 右手首
hipLeft 左腰
hipRight 右腰
kneeLeft 左膝
kneeRight 右膝
ankleLeft 左足首
ankleRight 右足首

それでは、ここからはAIトレーナーに見てもらう項目の実装を説明していきます。

2.3 肩と踵の位置が揃っているか

スクワットにおける正しい姿勢の一つとして、脚を肩幅くらいに開く、ということが言われています。脚を開きすぎたり、閉じすぎてしまうと負荷が掛かりづらくなり効果が低減してしまいます。

そこで、ここでは2.2で得られた骨格情報を用いて、肩と踵の位置が揃っているかを判定します。また、方と踵の位置が揃った状態で3秒のカウントダウンが始まり、スクワットスタートの合図の表示するようにしてみたいと思います。

⚠︎2.3~2.6のコードは2.2記載のコードの続きとなり、入力映像の各フレームに対して回帰的に処理を行う前提となっております。そのため、実際に動作させる際はインデントを2.2のl_keypoints = obj.keypoints行に合わせてください。

# 左右の肩と踵のx軸方向の距離が閾値を下回った時にform_check_startをインクリメント
if abs(keypoints["shoulderLeft"]["x"] - keypoints["ankleLeft"]["x"]) < 50 and abs(keypoints["shoulderRight"]["x"] - keypoints["ankleRight"]["x"]) < 50:
    form_check_start += 1
else:
    form_check_start = 0 

# 正しい姿勢(肩と踵の距離が閾値以内)になってから0~1秒、1~2秒、2~3秒の際にカウントダウンの数字を表示し、3~3.5秒でSTARTの文字を表示
if form_check_start > 30 and form_check_start <= 60 and squatcount == 0:
    cv2.putText(image_buffer, "3", (960, 640), cv2.FONT_HERSHEY_DUPLEX, 10, (255, 255, 255), 50, cv2.LINE_AA)
    cv2.putText(image_buffer, "3", (960, 640), cv2.FONT_HERSHEY_DUPLEX, 10, (255, 50, 0), 40, cv2.LINE_AA)
elif form_check_start > 60 and form_check_start <= 90 and squatcount == 0:
    cv2.putText(image_buffer, "2", (960, 640), cv2.FONT_HERSHEY_DUPLEX, 10, (255, 255, 255), 50, cv2.LINE_AA)
    cv2.putText(image_buffer, "2", (960, 640), cv2.FONT_HERSHEY_DUPLEX, 10, (255, 50, 0), 40, cv2.LINE_AA)
elif form_check_start > 90 and form_check_start <= 120 and squatcount == 0:
    cv2.putText(image_buffer, "1", (960, 640), cv2.FONT_HERSHEY_DUPLEX, 10, (255, 255, 255), 50, cv2.LINE_AA)
    cv2.putText(image_buffer, "1", (960, 640), cv2.FONT_HERSHEY_DUPLEX, 10, (255, 50, 0), 40, cv2.LINE_AA) 
elif form_check_start > 120 and form_check_start <= 135 and squatcount == 0:
    cv2.putText(image_buffer, "START!", (560, 590), cv2.FONT_HERSHEY_DUPLEX, 10, (255, 255, 255), 50, cv2.LINE_AA)
    cv2.putText(image_buffer, "START!", (560, 590), cv2.FONT_HERSHEY_DUPLEX, 10, (255, 50, 0), 40, cv2.LINE_AA)   
    flag_check_start = 1 # STARTしたらスクワットのカウントを開始するためのflagを立てる

2.4 腰がちゃんと落ちているか/スクワット回数カウント

もう一つ、スクワットで重要な要素は、腰をしっかり落とすことです。中途半端に曲げた状態では効果が薄れてしまいます。

そこで、ここでは2.2で得られた骨格情報を用いて、膝の角度を計算することで腰が落ちているかの判定をしていきます。理想の角度は90度と言われているので、その角度に近い時にスクワットのカウントをするようにします。また、膝の角度をヒートマップで表現することで、視覚的にあとどれくらい腰を落とせば良いのかがわかるようにしてみます。

### 左膝の角度の計算
# 膝の角度は腰・膝・足首の座標から計算するため、各ポイントの座標を変数に格納
hipLeft = np.array([keypoints["hipLeft"]["x"], [keypoints["hipLeft"]["y"]])
kneeLeft = np.array([keypoints["kneeLeft"]["x"], [keypoints["kneeLeft"]["y"]])
ankleLeft = np.array([keypoints["ankleLeft"]["x"], [keypoints["ankleLeft"]["y"]])

# 膝を中心として、腰・足首からのベクトルを計算                    
vec_hip_knee = hipLeft - kneeLeft
vec_ankle_knee = ankleLeft - kneeLeft

# ベクトルから角度を計算                    
length_vec_hip_knee = np.linalg.norm(vec_hip_knee)
length_vec_ankle_knee = np.linalg.norm(vec_ankle_knee)
inner_product = np.inner(vec_hip_knee, vec_ankle_knee)
cos = inner_product / (length_vec_hip_knee * length_vec_ankle_knee)
rad = np.arccos(cos)
degreeLeft = np.rad2deg(rad)

### 右膝の角度の計算
# 膝の角度は腰・膝・足首の座標から計算するため、各ポイントの座標を変数に格納
hipRight = np.array([keypoints["hipRight"]["x"], [keypoints["hipRight"]["y"]])
kneeRight = np.array([keypoints["kneeRight"]["x"], [keypoints["kneeRight"]["y"]])
ankleRight = np.array([keypoints["ankleRight"]["x"], [keypoints["ankleRight"]["y"]])

# 膝を中心として、腰・足首からのベクトルを計算 
vec_hip_knee = hipRight - kneeRight
vec_ankle_knee = ankleRight - kneeRight

# ベクトルから角度を計算                      
length_vec_hip_knee = np.linalg.norm(vec_hip_knee)
length_vec_ankle_knee = np.linalg.norm(vec_ankle_knee)
inner_product = np.inner(vec_hip_knee, vec_ankle_knee)
cos = inner_product / (length_vec_hip_knee * length_vec_ankle_knee)
rad = np.arccos(cos)
degreeRight = np.rad2deg(rad)     

### 左膝の角度のヒートマップ計算       
# 角度の扇型を描くための計算         
verticle = np.array([keypoints["kneeLeft"]["x"], 1280]) - kneeLeft # 1280はフレームのy軸の底(縦幅)
length_verticle = np.linalg.norm(verticle)
inner_product = np.inner(verticle, vec_ankle_knee)
cos = inner_product / (length_verticle * length_vec_ankle_knee)
rad = np.arccos(cos)
degree_start = np.rad2deg(rad)

# 角度の大→小で色が黄→赤になるように記述                    
if np.isnan(degreeLeft) != True:
    cv2.ellipse(image_buffer, (keypoints["kneeLeft"]["x"], keypoints["kneeLeft"]["y"]), (50,50), 90 + degree_start, 0, degreeLeft, (255, ((degreeLeft/180)*255)/2, ((degreeLeft/180)*255)/4), thickness=-1)
                                
### 右膝の角度のヒートマップ計算          
# 角度の扇型を描くための計算                 
verticle = np.array([keypoints["kneeRight"]["x"], 0]) - kneeRight # 0はフレームのy軸の頂点
length_verticle = np.linalg.norm(verticle)
inner_product = np.inner(verticle, vec_hip_knee)
cos = inner_product / (length_verticle * length_vec_hip_knee)
rad = np.arccos(cos)
degree_start = np.rad2deg(rad)

# 角度の大→小で色が黄→赤になるように記述                    
if np.isnan(degreeRight) != True:
    cv2.ellipse(image_buffer, (keypoints_xy[30], keypoints_xy[31]), (50,50), 270 + degree_start, 0, degreeRight, (255, ((degreeRight/180)*255)/2, ((degreeRight/180)*255)/4), thickness=-1)

### スクワット回数カウント
# 膝が伸びている(角度が130~170度)→膝が曲がっている(角度が110度以下)、の状態を1周した時にカウントするように設計
if round(degreeRight) < 170 and round(degreeRight) > 130 and round(degreeLeft) < 170 and round(degreeLeft) > 130 and flag == 1:
    flag = 0
    squatcount += 1
elif round(degreeRight) < 110 and round(degreeLeft) < 110 and flag == 0 and flag_check_start == 1:
    flag = 1 # 膝の角度が110度以下になるとカウント(撮影角度によって実際の角度と異なるため90より大きめ)

2.5 カウントリセット

ここでは、カウントをリセットする機能を実装します。 10回1セットで複数セット取り組みたい時、筋トレ中に宅配便が来てしまった時、(AIトレーナーが誤作動してしまった時)など、いろんな都合でカウントをリセットしたい場合があると思います。

PCに触れて解析を止めるのも手ですが、せっかく骨格情報を認識しているため、それらを使ってリセット機能を作ろうと思います。 今回は、両手を頭に触れた状態(頭を抱えるポーズ)を1秒間以上継続した時に、カウントをリセットすることにします。頭に近い骨格点としては、鼻があります。これと、左手首・右手首が一定の距離以内にあるかどうかを判定し、頭に両手を置いているかを識別します。

# 鼻と左手首・右手首が一定の距離にある場合にform_check_cancelをインクリメント
if abs(keypoints["nose"]["x"] - keypoints["wristLeft"]["x"]) < 100 and abs(keypoints["nose"]["x"] - keypoints["wristRight"]["x"]) < 100 and abs(keypoints["nose"]["y"] - keypoints["wristLeft"]["y"]) < 100 and abs(keypoints["nose"]["y"] - keypoints["wristRight"]["y"]) < 100:
    form_check_cancel += 1
else:
    form_check_cancel = 0

# 鼻と左手首・右手首が一定の距離にある状態が1秒以内
if form_check_cancel > 0 and form_check_cancel <= 60:
    cv2.putText(image_buffer, "Count Canceling...", (400, 590), 
    cv2.FONT_HERSHEY_DUPLEX, 5, (255, 255, 255), 30, cv2.LINE_AA)
    cv2.putText(image_buffer, "Count Canceling...", (400, 590), 
    cv2.FONT_HERSHEY_DUPLEX, 5, (0, 50, 255), 20, cv2.LINE_AA) 
# 鼻と左手首・右手首が一定の距離にある状態が1秒以上になったらリセット実行
elif form_check_cancel > 60 and form_check_cancel <= 90:
    cv2.putText(image_buffer, "Count Cancel!", (560, 590), 
    cv2.FONT_HERSHEY_DUPLEX, 5, (255, 255, 255), 30, cv2.LINE_AA)
    cv2.putText(image_buffer, "Count Cancel!", (560, 590), 
    cv2.FONT_HERSHEY_DUPLEX, 5, (0, 50, 255), 20, cv2.LINE_AA)   
    flag_check_start = 0 # 開始時のカウントダウン(肩と踵の位置チェック)に使うフラグ
    squatcount = 0 # カウントを0にする
    flag = 0 # カウント時に使うフラグを0にする

3. 筋トレAIトレーナーを使ってみる

以下の動画のように概ねうまくいきました。カウントが4回目くらいの時に、敢えて腰を落とし切らないでスクワットしてみています。 fpsは20くらいでリアルタイム解析ができていることが確認いただけます。

※動作環境は以下のとおりです。

  • 実機:Jetson AGX Orin
  • OS:Ubuntu 20.04
  • Jetpack:5.0.2
  • カメラ:TierIVカメラ 120deg

4. まとめ

今回の記事では、筋トレのサポートを行うAIトレーナーを作ってみました。 筋トレ熟練者からするとまだまだ改善の余地はあるかと思いますが、判定を結構シビアに設定したこともあり、初心者向けとしてはそれなりに厳しいAIトレーナーとなりました。

クリスマス、年末年始と運動不足が顕著化するシーズンですので、是非生活に運動を取り入れて乗り切りましょう。

引用文献

[1] DeepStream SDKによる動画像認識事始め2022 - ENGINEERING BLOG ドコモ開発者ブログ

[2] NVIDIA DeepStream SDK (JA-JP) | NVIDIA Developer

[3] GitHub - NVIDIA-AI-IOT/deepstream_python_apps: DeepStream SDK Python bindings and sample applications

[4] MetaData in the DeepStream SDK — DeepStream 6.3 Release documentation