AnyMotionを使ってフュージョンの動きを検出してみる

# 概要

みなさんご存知のフュージョンの動きを検出してみました。
AnyMotionを使用して関節の座標情報から角度の算出を行い、フュージョンっぽい姿勢かどうかを判定します。

# AnyMotionとは

[AnyMotion](https://anymotion.nttpc.co.jp/)とは、AIを用いた姿勢推定による動作解析APIプラットフォームサービスです。現在はトライアル中ということで無料で利用できるようです。

人物が写っている画像や動画に対してその人物の骨格座標の推定を行い、それをもとに指定した部位の角度を算出することで、身体動作の可視化／定量化を行うことができます。

GitHubにて[CLI](https://github.com/nttpc/anymotion-cli)や[Python SDK](https://github.com/nttpc/anymotion-python-sdk)、[Jupyter Notebookで書かれたExamples](https://github.com/nttpc/anymotion-examples)が公開されています。

# 目指すゴール
フュージョンは本来2人の戦士が対称のアクションを同じタイミングで行うことで成り立ちますが、現状AnyMotionでは2人以上の同時姿勢推定はできないという制約があります。そのため1人ずつ動作を解析することにします。

フュージョンは掛け声に合わせて3段階の動作があります。
![fusion-right-all2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/53819/4be6d8cf-8613-90fe-f638-8af162017410.gif)

1. 「フュー」腕を回しながらお互いにすり寄る動作
2. 「ジョン」腕を外側に、脚を内側にねじる動作
3. 「はっ！」上体を内側に曲げ指を合わせる動作

今回は簡単のために、動きが一瞬止まる2と3の姿勢をそれぞれ定義し、両方とも当てはまる動作がないかを解析します。指の高さなど細かい部分がズレていても気にしません。

上述の動作解析をフュージョンの左側の人と右側の人でそれぞれ行い、タイミングを問わず2人ともそれっぽい動きをしていればフュージョン成立！ということにします。

## フュージョンの姿勢の定義
身体がどの状態であればフュージョンとみなすのか、以下の表にまとめてみました。

- フェーズ1:「ジョン」腕を外側に、脚を内側にねじる動作
- フェーズ2:「はっ！」上体を内側に曲げ指を合わせる動作

| 左側の人（右側の人）      | 「ジョン」 | 「はっ！」 |
|--------|-----------|-----------|
| 左肩（右肩）   | 10〜90    | 130〜180  |
| 左ひじ（右ひじ） | 90〜180   | 40〜130   |
| 右肩（左肩）   | 120〜200  | 50〜150   |
| 右ひじ（左ひじ） | 150〜200    | 100〜170  |
| 左ひざ（右ひざ）   | 10〜80    | -         |


# 注意事項
- 性質上、撮影環境などの理由により骨格座標の推定が行われない、または不正確なことがあります。
- 骨格座標の推定には数分かかります（動画の長さ、大きさに比例して時間がかかります）

# Python SDKを使ってコードを書いてみた

## 使用したバージョン
- Python 3.8.0
- anymotion-sdk 1.0.1

## 事前準備

- AnyMotion APIのトークン発行
    - [AnyMotion](https://anymotion.nttpc.co.jp/)のページ右上 "Sign Up/Sign In" をクリックするとポータル画面に行くので、ユーザー登録してClient IDとSecretを取得する
- anymotion-sdkのインストール

  ```
  $ pip install anymotion-sdk
  ```

## 動画ファイルのアップロード〜骨格抽出
```python
from anymotion_sdk import Client
from PIL import Image, ImageDraw
import cv2
import matplotlib.pyplot as plt
import ffmpeg
import numpy as np

# AnyMotion APIの準備
client = Client(client_id="CLIENT_ID", 
                client_secret="CLIENT_SECRET")
filename = "left.mp4"

# 動画のアップロード（左側）
left_filename = "fusion_left.mp4"
left_movie_id = client.upload(left_filename).movie_id
print(f"movie_id: {left_movie_id}")

# 骨格抽出（キーポイント抽出）（左側）
left_keypoint_id = client.extract_keypoint(movie_id=left_movie_id)
left_extraction_result = client.wait_for_extraction(left_keypoint_id)
print(f"keypoint_id: {left_keypoint_id}")

# 動画のアップロード（右側）
right_filename = "fusion_right.mp4"
right_movie_id = client.upload(right_filename).movie_id
print(f"movie_id: {right_movie_id}")

# 骨格抽出（キーポイント抽出）（右側）
right_keypoint_id = client.extract_keypoint(movie_id=right_movie_id)
right_extraction_result = client.wait_for_extraction(right_keypoint_id)
print(f"keypoint_id: {right_keypoint_id}")
```

## 角度の取得

角度を取得する部位を指定します。指定の仕方は[公式ドキュメント](https://docs.anymotion.jp/analysis.html)に記載があります。

```python
# 角度の解析ルールの定義
analyze_angles_rule = [
    # left arm
    {
        "analysisType": "vectorAngle",
        "points": ["rightShoulder", "leftShoulder", "leftElbow"]
    },
    {
        "analysisType": "vectorAngle",
        "points": ["leftShoulder", "leftElbow", "leftWrist"]
    },
    # right arm
    {
        "analysisType": "vectorAngle",
        "points": ["leftShoulder", "rightShoulder", "rightElbow"]
    },
    {
        "analysisType": "vectorAngle",
        "points": ["rightShoulder", "rightElbow", "rightWrist"]
    },
    # left leg
    {
        "analysisType": "vectorAngle",
        "points": ["rightHip", "leftHip", "leftKnee"]
    },
    # right leg
    {
        "analysisType": "vectorAngle",
        "points": ["leftHip", "rightHip", "rightKnee"]
    },
]
# 角度の解析開始（左側）
left_analysis_id = client.analyze_keypoint(left_keypoint_id, rule=analyze_angles_rule)
# 角度情報の取得
left_analysis_result = client.wait_for_analysis(left_analysis_id).json
# dict形式の結果をlist形式へ変換（同時に数値をfloatからintへ変換）
left_angles = [list(map(lambda v: int(v) if v else None, x["values"])) for x in left_analysis_result["result"]]
print("angles analyzed.")

# 角度の解析開始（右側）
right_analysis_id = client.analyze_keypoint(right_keypoint_id, rule=analyze_angles_rule)
right_analysis_result = client.wait_for_analysis(right_analysis_id).json
right_angles = [list(map(lambda v: int(v) if v else None, x["values"])) for x in right_analysis_result["result"]]
print("angles analyzed.")
```

## フュージョン検出

```python
def is_fusion_phase1(pos, a, b, c, d, e, f):
    # pos: left or right
    # print(a, b, c, d, e, f)
    if pos == "left":  # 左側に立つ人をチェックする
        if not e:
            e = 70  # 脚の角度が取れていない場合を考慮
        return (a in range(10, 90) and \
               b in range(90, 180) and \
               c in range(120, 200) and \
               d in range(150, 200) and \
               e in range(10, 80))
    else:  # 右側に立つ人をチェックする
        if not f:
            f = 70  # 脚の角度が取れていない場合を考慮
        return (c in range(10, 90) and \
               d in range(90, 180) and \
               a in range(120, 200) and \
               b in range(150, 200) and \
               f in range(10,80))

def is_fusion_phase2(pos, a, b, c, d, e, f):
    # pos: left or right
    # print(a, b, c, d, e, f)
    if pos == "left":  # 左側に立つ人をチェックする
        return a in range(130, 180) and \
               b in range(40, 130) and \
               c in range(50, 150) and \
               d in range(100, 170)
    else:
        return c in range(130, 180) and \
               d in range(40, 130) and \
               a in range(50, 150) and \
               b in range(100, 170)

def check_fusion(angles, position):
    """
        angles: 角度情報
        position: left or right
    """
    # 各ステップを検出したかを格納するフラグ
    phase1 = False
    phase2 = False
    # 該当フレームを格納するリスト
    p1 = []
    p2 = []
    for i in range(len(angles[0])):
        if is_fusion_phase1(position, angles[0][i], angles[1][i], angles[2][i], angles[3][i],
                            angles[4][i], angles[5][i]):
            print(i, "Phase1!!!")
            phase1 = True
            p1.append(i)
        elif phase1 and is_fusion_phase2(position, angles[0][i], angles[1][i], angles[2][i], angles[3][i],
                              angles[4][i], angles[5][i]):
            print(i, "Phase2!!!")
            phase2 = True
            p2.append(i)

    if phase1 and phase2:
        print("Fusion!!!!!!")
        
    return ((phase1 and phase2), p1, p2)

left_result, left_p1, left_p2 = check_fusion(left_angles, "left")
right_result, right_p1, right_p2 = check_fusion(right_angles, "right")
```

## 検出したフュージョンのフレームを使ってGIFアニメーションを生成する
```python
# 動画の向きを確認する
def check_rotation(path_video_file):
    meta_dict = ffmpeg.probe(path_video_file)

    rotateCode = None
    try:
        if int(meta_dict['streams'][0]['tags']['rotate']) == 90:
            rotateCode = cv2.ROTATE_90_CLOCKWISE
        elif int(meta_dict['streams'][0]['tags']['rotate']) == 180:
            rotateCode = cv2.ROTATE_180
        elif int(meta_dict['streams'][0]['tags']['rotate']) == 270:
            rotateCode = cv2.ROTATE_90_COUNTERCLOCKWISE
    except:
        pass

    return rotateCode

# 動画の指定したフレームを取得する
def get_frame_img(filename, frame_num):
    reader = cv2.VideoCapture(filename)
    rotateCode = check_rotation(filename)
    reader.set(1, frame_num)
    ret, frame_img = reader.read()
    reader.release()
    
    if not ret:
        return None
    if rotateCode:
        frame_img = cv2.rotate(frame_img, rotateCode)

    return frame_img

# 2つのフレームを横に連結する
def get_frame_img_hconcat(l_filename, r_filename, l_framenum, r_framenum):
    l_img = get_frame_img(l_filename, l_framenum)
    r_img = get_frame_img(r_filename, r_framenum)
    
    img = cv2.hconcat([l_img, r_img])
    return img

# 検出したフレームの中央値を取得
left_p1_center = left_p1[int(len(left_p1)/2)]
left_p2_center = left_p2[int(len(left_p2)/2)]
right_p1_center = right_p1[int(len(right_p1)/2)]
right_p2_center = right_p2[int(len(right_p2)/2)]

# Phase1の画像を横方向に結合する
p1_img = get_frame_img_hconcat(left_filename, right_filename, left_p1_center, right_p1_center)

# Phase2の画像を横方向に結合する
p2_img = get_frame_img_hconcat(left_filename, right_filename, left_p2_center, right_p2_center)

# numpy arrayからPILのImageに変換する
im1 = Image.fromarray(cv2.cvtColor(p1_img, cv2.COLOR_BGR2RGB))
im2 = Image.fromarray(cv2.cvtColor(p2_img, cv2.COLOR_BGR2RGB))

# GIFアニメを生成する
im1.save('fusion.gif', save_all=True, append_images=[im2], optimize=False, duration=700, loop=0)
```

![fusion.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/53819/d406f3ee-884a-1bb5-ca5e-0cb52f438007.gif)

（手が物と被ってしまったり指の位置がズレてたり、いろいろ言いたいことはあると思いますが大目に見てください…）

## ソースコード全体
[gist](https://gist.github.com/staybuzz/26c39f367a668f700d2876be9ddf077d)にJupyter Notebookの形式でアップロードしました。

# おわりに
AnyMotionを使って推定した姿勢情報を活用してフュージョンの姿勢を検出してみました。
このようなことを行うことで筋トレのフォームチェックをするといったパーソナルトレーナー的なことも出来るようです（[いえとれ](https://ietore.anymotion.jp/)）。
他にもどんなことができるのかいろいろ試していきたいと思います。

# 参考
- [OpenPoseを使って、かめはめ波を撃ってみた](https://qiita.com/oka_1207/items/ec5010d4ed53a185c175)
- [python - Frame from video is upside down after extracting - Stack Overflow](https://stackoverflow.com/questions/53097092/frame-from-video-is-upside-down-after-extracting)
- [Python, OpenCVで画像を縦・横に連結 (hconcat, vconcat, np.tile)](https://note.nkmk.me/python-opencv-hconcat-vconcat-np-tile/)