ピッチャーの未来はAIが見る──Python+MediaPipeでフォームを可視化！

# 背景
自分は草野球でピッチャーをやっています。ピッチャーなら、誰しも球速を速くしたいと思うものです。そこで今回は、AIの力を駆使して、自分のフォームの可視化にチャレンジしてみたいと思います。

# やったこと
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/542172/b2928b4e-fa0a-46e3-9404-e9f6f4a4f1ff.gif" width="500" />

このようなピッチングフォームをスマホカメラで撮影し、mediapipeで骨格検出を行い、以下のような動作検出結果を得ました。

![output.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/542172/aecaccf8-866e-47de-bc9d-86db00554c42.gif)


この動作検出結果から各関節の位置、速度を得られますので、プロ野球選手と比較することで、自分の動作のどこが良くないのかを特定できるのではないかと考えています。

# 準備物
あらかじめ、mediapipe、opencvをpipにてインストールしておきます。
```bash
pip install mediapipe opencv-python
```
また、骨格検出用のモデルも用意しておきます。
[pose_landmarker_full.task](https://storage.googleapis.com/mediapipe-models/pose_landmarker/pose_landmarker_full/float16/latest/pose_landmarker_full.task)

画像解析結果を動画にするため、ffmpegもインストールしておきます。
[ffmpeg](https://www.ffmpeg.org/download.html)

また、フォルダは以下の構成で行きたいと思います。
create_database.pyで動作検出動画および各器官の座標データデータベースを作成していきたいと思います。
```plaintext
# フォルダ構成例
解析フォルダ/
├── analysis/ 　　　　　　　　　　    #解析フォルダ
│   ├── create_database.py          #データベース作成スクリプト
│   ├── DB.csv                      #動作検出結果保存csv
│   ├── pose_landmarker_full.task   #モデル
│   └── output                      #解析結果フォルダ
│        ├── 2025-04-13-13-30_user1_condition1
│        │   └── output.mp4         #解析動画
│        ├── 2025-04-13-14-30_user1_condition2
│        │   └── output.mp4         #解析動画
│        └── 2025-04-13-15-30_user2_condition1
│            └── output.mp4         #解析動画
│    
└── data/                           #生動画フォルダ
    ├── 2025-04-13-13-30_user1_condition1.mp4
    ├── 2025-04-13-14-30_user1_condition2.mp4
    └── 2025-04-13-15-30_user2_condition1.mp4
```

# Mediapipeを用いた骨格検出について
画像からランドマークを検出するのは
```python
result = landmarker.detect(image)
```
で可能です。
result.pose_landmarksの中身は、
```python
[[NormalizedLandmark(x=0.2937493920326233, y=0.22164519131183624, z=-0.132131427526474, visibility=0.9998205304145813, presence=0.9995495676994324),
  NormalizedLandmark(x=0.2884519100189209, y=0.20045679807662964, z=-0.12086278200149536, visibility=0.9997561573982239, presence=0.9992701411247253),
  NormalizedLandmark(x=0.28779274225234985, y=0.1998022049665451, z=-0.12090261280536652, visibility=0.9997262358665466, presence=0.9992595314979553),
  NormalizedLandmark(x=0.2870274782180786, y=0.1992015242576599, z=-0.12091138958930969, visibility=0.9997233748435974, presence=0.9992117881774902),
  NormalizedLandmark(x=0.28646114468574524, y=0.20025983452796936, z=-0.15471163392066956, visibility=0.9997621178627014, presence=0.9993453621864319),
  NormalizedLandmark(x=0.28440162539482117, y=0.1995232105255127, z=-0.15472431480884552, visibility=0.9997298121452332, presence=0.9992952346801758),
  NormalizedLandmark(x=0.2821614742279053, y=0.1987985223531723, z=-0.15474040806293488, visibility=0.9997051358222961, presence=0.9992691874504089),
  NormalizedLandmark(x=0.27445781230926514, y=0.2031809389591217, z=-0.0428277887403965, visibility=0.9994569420814514, presence=0.9993908405303955),
  NormalizedLandmark(x=0.2675430476665497, y=0.2037878930568695, z=-0.1942434161901474, visibility=0.9998270869255066, presence=0.9994966983795166),
  NormalizedLandmark(x=0.2895558178424835, y=0.23831695318222046, z=-0.09848137944936752, visibility=0.9998164772987366, presence=0.9997425675392151),
  NormalizedLandmark(x=0.28730958700180054, y=0.23767462372779846, z=-0.1422593593597412, visibility=0.999853253364563, presence=0.9998015761375427),
  NormalizedLandmark(x=0.27675729990005493, y=0.29828208684921265, z=0.06098111346364021, visibility=0.9996931552886963, presence=0.9998210072517395),
  NormalizedLandmark(x=0.23509235680103302, y=0.310982346534729, z=-0.22888535261154175, visibility=0.9999377727508545, presence=0.9998416900634766),
  NormalizedLandmark(x=0.29666876792907715, y=0.398720383644104, z=0.06673865020275116, visibility=0.22555731236934662, presence=0.9990183115005493),
  NormalizedLandmark(x=0.26787251234054565, y=0.4326116144657135, z=-0.216049924492836, visibility=0.9889342188835144, presence=0.9989252686500549),
  NormalizedLandmark(x=0.33765488862991333, y=0.36677297949790955, z=-0.1054496094584465, visibility=0.5532768964767456, presence=0.9994358420372009),
  NormalizedLandmark(x=0.3242368698120117, y=0.3696664273738861, z=-0.1329113095998764, visibility=0.9830930233001709, presence=0.9997656941413879),
  NormalizedLandmark(x=0.3536413311958313, y=0.35681384801864624, z=-0.13259077072143555, visibility=0.5644939541816711, presence=0.9993977546691895),
  NormalizedLandmark(x=0.34238317608833313, y=0.36138421297073364, z=-0.1504877507686615, visibility=0.9703927040100098, presence=0.9997437596321106),
  NormalizedLandmark(x=0.3513346314430237, y=0.34363114833831787, z=-0.148756742477417, visibility=0.5882216095924377, presence=0.9993553757667542),
  NormalizedLandmark(x=0.33912062644958496, y=0.3403850495815277, z=-0.15075184404850006, visibility=0.9674960970878601, presence=0.9997546076774597),
  NormalizedLandmark(x=0.34505146741867065, y=0.34376734495162964, z=-0.11970783025026321, visibility=0.5923910737037659, presence=0.9994912147521973),
  NormalizedLandmark(x=0.333903431892395, y=0.3416404724121094, z=-0.12902593612670898, visibility=0.9423821568489075, presence=0.9998030066490173),
  NormalizedLandmark(x=0.2741575241088867, y=0.541138231754303, z=0.08584432303905487, visibility=0.9980929493904114, presence=0.9991779923439026),
  NormalizedLandmark(x=0.24327510595321655, y=0.5430638194084167, z=-0.08590060472488403, visibility=0.9983502626419067, presence=0.9987867474555969),
  NormalizedLandmark(x=0.2763270139694214, y=0.6973086595535278, z=0.20918267965316772, visibility=0.6617425084114075, presence=0.9996604919433594),
  NormalizedLandmark(x=0.2567470669746399, y=0.7118337154388428, z=0.014631092548370361, visibility=0.9669117331504822, presence=0.9997362494468689),
  NormalizedLandmark(x=0.27007240056991577, y=0.8409380912780762, z=0.36986884474754333, visibility=0.8033745288848877, presence=0.9994743466377258),
  NormalizedLandmark(x=0.2515407204627991, y=0.8723800182342529, z=0.16001680493354797, visibility=0.969566822052002, presence=0.9993405938148499),
  NormalizedLandmark(x=0.26093214750289917, y=0.8635669946670532, z=0.38253727555274963, visibility=0.8256247043609619, presence=0.9991625547409058),
  NormalizedLandmark(x=0.24297018349170685, y=0.9010763168334961, z=0.17067016661167145, visibility=0.9261419177055359, presence=0.9987022876739502),
  NormalizedLandmark(x=0.3100980222225189, y=0.8797546625137329, z=0.3281865119934082, visibility=0.9008771777153015, presence=0.9985620379447937),
  NormalizedLandmark(x=0.280405730009079, y=0.9193171262741089, z=0.08664955198764801, visibility=0.9672218561172485, presence=0.9970423579216003)]]
```
このような感じで、各器官の座標が正規化された形で保存されています。
各器官に対応するindexは
[公式サイト](https://ai.google.dev/edge/mediapipe/solutions/vision/pose_landmarker?hl=ja#pose_landmarker_model)
が参考になります。

得られたランドマークの座標から、3D座標軸でのプロットはsymfoさんの[サイト](https://symfoware.blog.fc2.com/blog-entry-2721.html)を参考にさせていただきます。

```python
def plot_world_landmarks(
    plt,
    ax,
    landmarks,
    visibility_th=0.5,
):
    landmark_point = []
    for index, landmark in enumerate(landmarks):
        landmark_point.append(
            [landmark.visibility, (landmark.x, landmark.y, landmark.z)])
    face_index_list = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
    right_arm_index_list = [11, 13, 15, 17, 19, 21]
    left_arm_index_list = [12, 14, 16, 18, 20, 22]
    right_body_side_index_list = [11, 23, 25, 27, 29, 31]
    left_body_side_index_list = [12, 24, 26, 28, 30, 32]
    shoulder_index_list = [11, 12]
    waist_index_list = [23, 24]
    # 顔
    face_x, face_y, face_z = [], [], []
    for index in face_index_list:
        point = landmark_point[index][1]
        face_x.append(point[0])
        face_y.append(point[2])
        face_z.append(point[1] * (-1))
    # 右腕
    right_arm_x, right_arm_y, right_arm_z = [], [], []
    for index in right_arm_index_list:
        point = landmark_point[index][1]
        right_arm_x.append(point[0])
        right_arm_y.append(point[2])
        right_arm_z.append(point[1] * (-1))
    # 左腕
    left_arm_x, left_arm_y, left_arm_z = [], [], []
    for index in left_arm_index_list:
        point = landmark_point[index][1]
        left_arm_x.append(point[0])
        left_arm_y.append(point[2])
        left_arm_z.append(point[1] * (-1))
    # 右半身
    right_body_side_x, right_body_side_y, right_body_side_z = [], [], []
    for index in right_body_side_index_list:
        point = landmark_point[index][1]
        right_body_side_x.append(point[0])
        right_body_side_y.append(point[2])
        right_body_side_z.append(point[1] * (-1))
    # 左半身
    left_body_side_x, left_body_side_y, left_body_side_z = [], [], []
    for index in left_body_side_index_list:
        point = landmark_point[index][1]
        left_body_side_x.append(point[0])
        left_body_side_y.append(point[2])
        left_body_side_z.append(point[1] * (-1))
    # 肩
    shoulder_x, shoulder_y, shoulder_z = [], [], []
    for index in shoulder_index_list:
        point = landmark_point[index][1]
        shoulder_x.append(point[0])
        shoulder_y.append(point[2])
        shoulder_z.append(point[1] * (-1))
    # 腰
    waist_x, waist_y, waist_z = [], [], []
    for index in waist_index_list:
        point = landmark_point[index][1]
        waist_x.append(point[0])
        waist_y.append(point[2])
        waist_z.append(point[1] * (-1))
            
    ax.cla()
    ax.set_xlim3d(-1, 1)
    ax.set_ylim3d(-1, 1)
    ax.set_zlim3d(-1, 1)
    ax.scatter(face_x, face_y, face_z)
    ax.plot(right_arm_x, right_arm_y, right_arm_z)
    ax.plot(left_arm_x, left_arm_y, left_arm_z)
    ax.plot(right_body_side_x, right_body_side_y, right_body_side_z)
    ax.plot(left_body_side_x, left_body_side_y, left_body_side_z)
    ax.plot(shoulder_x, shoulder_y, shoulder_z)
    ax.plot(waist_x, waist_y, waist_z)
```

# 手順
解析動画を作成、保存また、各関節の座標の保存を行う手順は以下のとおりです。
1. ライブラリの読み込み
1. ランドマーカーの読み込み
1. 生動画パスの読み込み
1. 各動画を読み込み、1フレームごとに画像を読み込み、骨格検出を行い、結果をデータフレームに保存
1. 保存した画像を動画に


## ライブラリの読み込み
必要なライブラリを読み込んでいきます
```python
from matplotlib import pyplot as plt
import mediapipe as mp
import numpy as np
import pandas as pd
import glob
from tqdm import tqdm
import os
import cv2
```
## ランドマーカーの読み込み
ランドマーカーを読み込んでいきます。create_database.pyとpose_landmarker_full.taskは同じ階層にあることを前提としてます。
```python
BaseOptions = mp.tasks.BaseOptions
PoseLandmarker = mp.tasks.vision.PoseLandmarker
PoseLandmarkerOptions = mp.tasks.vision.PoseLandmarkerOptions
VisionRunningMode = mp.tasks.vision.RunningMode
options = PoseLandmarkerOptions(
    base_options=BaseOptions(model_asset_path='pose_landmarker_full.task'),
    running_mode=VisionRunningMode.IMAGE,
    num_poses=1)
landmarker = PoseLandmarker.create_from_options(options)
```

## 生動画パスの読み込み
生動画は../dataの中に保存されていますので、globでパスを取得します。
```python
baseDir = "../"
dataDir = baseDir + "data"
video_path_list = glob.glob(dataDir + "/*")
```

## 各動画を読み込み、1フレームごとに画像を読み込み、骨格検出を行い、結果をデータフレームに保存
※各動画のファイル名は
```python
日付_ユーザー名_条件.mp4
```
というようにアンダーバー区切りで、日付、ユーザー名、条件が並んでいる形になります。データフレームに保存する際に日付、ユーザー名、条件が必要となります。

```python
if(os.path.isfile("DB.csv")): #DB.csvが存在していれば、読み込み
    df = pd.read_csv("DB.csv")
else:
    df = pd.DataFrame()

cnt = 0 #データフレームの行番号
for v in video_path_list: #動画パスをループ
    fn = os.path.basename(v) #ベースネーム取得
    fn_no_ext, _ = os.path.splitext(fn) #拡張子を除く
    cap = cv2.VideoCapture(v) #キャプチャ取得

    if not cap.isOpened():
        #キャプチャがオープンできない場合はエラー
        raise IOError("Could not open video file")

    frame_width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) #画像幅取得
    frame_height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) #画像高さ取得

    fps = cap.get(cv2.CAP_PROP_FPS) #FPS取得
    tmp = fn_no_ext.split("_") #ファイル名から、日付、ユーザー名、条件を取得
    MeasTime = tmp[0]
    UserID = tmp[1]
    Condition = tmp[2]
    tdf = df[(df["MeasTime"] == MeasTime) &
             (df["UserID"] == UserID) &
             (df["Condition"] == Condition)]
    if(len(tdf) != 0): continue #すでに解析済みなら、飛ばす
    print(fn)
    print("Frames per second: ", fps)

    totalframe = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) #トータルフレームの取得
    print(totalframe)
    for i in tqdm(range(totalframe)): #各フレームごとの処理
        #print(f"{cnt / totalframe * 100:.1f}")
        ret, frame = cap.read() #フレームを取得
        os.makedirs(f'./output/{fn_no_ext}/origin', exist_ok=True) #取得したフレームの保存先フォルダの作成
        filename = f'./output/{fn_no_ext}/origin/frame_{i:04d}.jpg'
        cv2.imwrite(filename, frame) #保存先フォルダに画像を保存

        mp_image = mp.Image.create_from_file(filename) #保存先フォルダの画像を読み込み
        pose_landmarker_result = landmarker.detect(mp_image) #ランドマークの検出

        for marks in pose_landmarker_result.pose_landmarks:
            res = get_landmarks_dic(marks) #各ランドマークから、対象となる器官の座標を取得
            # get_landmarks_dic関数は後述

        for key in res.keys():
            df.loc[cnt, key] = res[key] #器官の座標がdic型で格納されているので、データフレームの該当keyに格納
        df.loc[cnt, "time"] = i / fps #時間を取得
        df.loc[cnt, "MeasTime"] = MeasTime #測定時刻を取得
        df.loc[cnt, "UserID"] = UserID #ユーザー名を取得
        df.loc[cnt, "Condition"] = Condition #条件を取得
        
        # OpenCVでは色がBGR順なのでRGB順に変更
        frame2 = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)

        # フレームを表示
        #plt.imshow(frame2)
        #plt.show()
        fig = plt.figure()
        ax = fig.add_subplot(111, projection='3d')
        fig.subplots_adjust(left=0.0, right=1, bottom=0, top=1)
        for marks in pose_landmarker_result.pose_landmarks:
            plot_world_landmarks(plt, ax, marks, azim = -90, elev = 0)
        os.makedirs(f'./output/{fn_no_ext}/pose', exist_ok=True)
        filename2 = f'./output/{fn_no_ext}/pose/frame_{i:04d}.jpg'
        plt.savefig(filename2)
        #plt.show()
            

        cnt += 1
    cap.release()
df.to_csv("DB.csv")
```

get_landmarks_dic関数は以下のとおりです。
```python
# 検出結果の描画
def get_landmarks_dic(landmarks, visibility_th=0.5,):
    landmark_point = []
    for index, landmark in enumerate(landmarks):
        landmark_point.append(
            [landmark.visibility, (landmark.x, landmark.y, landmark.z)])
    face_index_list = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
    right_arm_index_list = [11, 13, 15, 17, 19, 21]
    left_arm_index_list = [12, 14, 16, 18, 20, 22]
    right_body_side_index_list = [11, 23, 25, 27, 29, 31]
    left_body_side_index_list = [12, 24, 26, 28, 30, 32]
    shoulder_index_list = [11, 12]
    waist_index_list = [23, 24]
    # 顔
    face_x, face_y, face_z = [], [], []
    for index in face_index_list:
        point = landmark_point[index][1]
        face_x.append(point[0])
        face_y.append(point[2])
        face_z.append(point[1] * (-1))
    # 右腕
    right_arm_x, right_arm_y, right_arm_z = [], [], []
    for index in right_arm_index_list:
        point = landmark_point[index][1]
        right_arm_x.append(point[0])
        right_arm_y.append(point[2])
        right_arm_z.append(point[1] * (-1))
    # 左腕
    left_arm_x, left_arm_y, left_arm_z = [], [], []
    for index in left_arm_index_list:
        point = landmark_point[index][1]
        left_arm_x.append(point[0])
        left_arm_y.append(point[2])
        left_arm_z.append(point[1] * (-1))
    # 右半身
    right_body_side_x, right_body_side_y, right_body_side_z = [], [], []
    for index in right_body_side_index_list:
        point = landmark_point[index][1]
        right_body_side_x.append(point[0])
        right_body_side_y.append(point[2])
        right_body_side_z.append(point[1] * (-1))
    # 左半身
    left_body_side_x, left_body_side_y, left_body_side_z = [], [], []
    for index in left_body_side_index_list:
        point = landmark_point[index][1]
        left_body_side_x.append(point[0])
        left_body_side_y.append(point[2])
        left_body_side_z.append(point[1] * (-1))
    # 肩
    shoulder_x, shoulder_y, shoulder_z = [], [], []
    for index in shoulder_index_list:
        point = landmark_point[index][1]
        shoulder_x.append(point[0])
        shoulder_y.append(point[2])
        shoulder_z.append(point[1] * (-1))
    # 腰
    waist_x, waist_y, waist_z = [], [], []
    for index in waist_index_list:
        point = landmark_point[index][1]
        waist_x.append(point[0])
        waist_y.append(point[2])
        waist_z.append(point[1] * (-1))

    # 必要なものだけ取る
    parts = ["nose",
             "right_wrist", "right_elbow",  "right_shoulder", "right_waist", "right_knee", "right_ankle", "right_heel", "right_toe",
             "left_wrist", "left_elbow",  "left_shoulder", "left_waist", "left_knee", "left_ankle", "left_heel", "left_toe",
            ]
    targetIndexList = [0, 16, 14, 12, 24, 26, 28, 30, 32, 15, 13, 11, 23, 25, 27, 29, 31]
        
    res = {
          }
    
    for p, index in zip(parts, targetIndexList):
        point = landmark_point[index][1]
        res[f"{p}_x"] = point[0]
        res[f"{p}_y"] = point[2]
        res[f"{p}_z"] = point[1] * (-1)
    return res
```
これで必要な器官の座標のみ取得できます。

## 保存した画像を動画に
./outputフォルダのposeに保存された画像をffmpegを用いて動画化します

```python
for f in os.listdir("./output"):
    if(f[0] == "."): continue
    print(f)
    cmd = f"ffmpeg -framerate 30 -i ./output/{f}/pose/frame_%04d.jpg ./output/{f}/output.mp4 -y" #framerateは動画に合わせる
    os.system(cmd)
```

これにより、冒頭の動画が得られます。

![output.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/542172/aecaccf8-866e-47de-bc9d-86db00554c42.gif)

# 各座標の時系列データの可視化
各動画ごとの器官の座標の時系列データは以下のスクリプトで可視化できます。
以下の例は、右手首のx座標を表示しています。
```python
for UserID in df["UserID"].unique():
    tdf = df[df["UserID"] == UserID]
    print(UserID)
    for MeasTime in tdf["MeasTime"].unique():
        print(MeasTime)
        tdf2 = tdf[tdf["MeasTime"] == MeasTime]
        plt.figure(figsize=(15, 4))
        plt.plot(tdf2["time"], tdf2["right_wrist_x"] - tdf2["right_wrist_x"].values[0])
        plt.title(f"{UserID} {MeasTime}")
        plt.show()
```

これにより、以下のような時系列データが可視化されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/542172/e2a0b98c-74a4-4ae8-a42a-37c3be20cf67.png)

このデータは、4回ピッチングしたときのデータであるため、4つの山があります。このデータの座標の軌跡を微分することで、速度が求められます。
微分したものがこちらです。このように、軌跡、速度を分析することで、どんなフォームがよりよいフォームなのかの知見が得られそうです。

![Screenshot 2025-04-13 14.34.41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/542172/1ea678a6-d41c-411d-ab7f-83185f109a33.png)


# Limitation
今回は、ピッチング動画を可視化して、ある関節の座標の時系列データを可視化しました。
課題としては、映像からは長さの絶対情報は取得できないため、関節角度のように長さに依存しないパラメータで分析をする必要がありそうです。
今後は、プロ野球選手との比較、あるいは日内、日間の変動も分析できればとおもいます