🙌

機械学習で手の形を識別しよう!(AI, Python, mediapipe, Pytorch)

に公開
3

はじめに

生成AI(ChatGPTなど)の進歩はすさまじく、人間がやっていた作業の多くをAIを活用することで大幅に時間短縮することが出来ています。しかし、ChatGPTは何でもできるわけではなく、あくまでたくさんの知識をもっている相談相手ぐらいの感覚です。目的に応じたAIを自分で設計できるようになりましょう。
プログラムとモデルだけが欲しい場合は、以下のgithubにコードとモデルをあげてるので自由に使ってください。
https://github.com/tanileo/HandClassifierAI

進め方

この記事では、手の形(グー、チョキ、パー)を分類するAIを以下の順序で作っていきます。補足説明が必要なところでは、その都度説明を行います。

  1. 環境づくり
  2. mediapipeで手のランドマークを取得
  3. 手のランドマーク座標を使って、学習データの作成
  4. Pytorchで学習データを使い機械学習を行い、AIをつくる
  5. 作成できたAIを使って、リアルタイムで手の形を識別する

1. 環境づくり

以下の点に該当する方は、お使いのPCで環境をつくらないといけません。すこしめんどくさい作業になりますが、環境を作ってしまうと、いつでもPC上でPythonを動かせるようになるので、頑張りましょう!

  • お使いのPCにPythonをインストールしたことがない方
  • コードエディター(VScode、Cursorなど)をインストールしていない方

以下のページを参考にして、環境を作ってください
VScodeの設定までできたら完了です。PyCharmは使いません。
https://zenn.dev/picaneru/articles/a5e6c4d9836c5b

また、今回使用するライブラリをまとめてインストールしておきましょう。

pip install opencv-python mediapipe pytorch

その他、必要なライブラリがあったらその都度インストールしてください。
今回、動かすプログラムは、すべて同じフォルダ内で実行してください。

2. mediapipeで手のランドマークを取得

mediapipeとは?

mediapipeは、Googleが提供している人間の骨格を簡単に推定することができるライブラリです。

ライブラリとは?

自分でプログラムを作る際に、難しいけどよく使う処理ってあるよね?
すべてのコードを自分だけど書いてしまうと、プログラムが長くなってしまうので、よく使う処理は、ライブラリとしてみんなが自由に使えるようになっている。mediapipeやcv2もその一例です。ライブラリを使うことで簡単にプログラムが作れます。

今回は、mediapipeを使って手の骨格のランドマークを画像から取得します。以下がコードです。まずは、コピペして実行してみましょう。

mediapipe_test.py
# 使用するライブラリのインポート
import cv2               # 画像処理用のライブラリ
import mediapipe as mp   # 手の検出とランドマーク推定用のライブラリ

# 設定
mp_hands = mp.solutions.hands                                           # 手検出モジュールの初期化
mp_drawing = mp.solutions.drawing_utils                                 # ランドマーク描画用ユーティリティ
hands = mp_hands.Hands(max_num_hands=1, min_detection_confidence=0.5)   # 手検出モデルの初期化(認識する手の数=1、最小検出信頼度=0.5)

# メインループ
cap = cv2.VideoCapture(0)

# 無限ループでカメラ映像を処理
while True:
    ret, frame = cap.read()
    if not ret:
        break

    frame = cv2.flip(frame, 1)
    rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
    results = hands.process(rgb)

    if results.multi_hand_landmarks:                                                      # 手が検出された場合
        for hand_landmarks in results.multi_hand_landmarks:
            mp_drawing.draw_landmarks(frame, hand_landmarks, mp_hands.HAND_CONNECTIONS)   # ランドマークと接続線を描画

    cv2.imshow("mediapipe test", frame)
    if cv2.waitKey(1) & 0xFF == 27:  # ESCで終了
        break

cap.release()
cv2.destroyAllWindows()

このプログラムでは、カメラから取得した各フレームからmediapipeで骨格推定を行い、推定したランドマークをカメラの映像に重ねて表示します。プログラムを停止する場合は、カメラの映像が表示されているウィンドウを選択した状態でESCキーを押すか、プログラムが実行されているVScodeのターミナル上で「Ctrl+C」を押して停止してください。

これにより、手のランドマーク座標を取得することが出来ました。次は、このプログラムを利用して機械学習で使う学習データを作成するプログラムを作ります。

3. 手のランドマーク座標を使って、学習データの作成

学習データを作る前に、学習データとは、何か、どのように使うのか説明します。

AIとは?

AIとは簡単に言うと、ある値を入力したら、それに対する何かしらの出力が返ってくる関数です。
例えば、ChatGPTのような生成AIは、質問を入力すると、質問に対する答えを出力してくれます。
画像生成AIも、作ってほしい画像をプロンプトで説明することで、そのプロンプトに沿った画像を出力してくれます。

教師あり学習とは?

教師あり学習とは、入力とそれに対応した教師データ(出力)を使って、機械学習を行うことです。
この学習によって、ある入力をいれたら、それに対応する教師データが出力されるような関数を作ります。

学習データの使い方が分かったようなので、学習データをつくるプログラムを動かしてみましょう。
このプログラムは、1回の実行につき、1つの手の形(グー、チョキ、パーのどれか)のデータを作成します。プログラムを実行すると、ターミナル上に入力欄が出てきます。そこに、これからする手の形の名前を英語で入力しましょう。(グー:Rock、チョキ:Scissors、パー:Paper)
測定は、右手でしてください。両方の手でデータを作りたい場合は、プログラムを自分でいじってみてください。右手だけでも、一応、左手の予測もできるようにしています。
入力して、Enterを押すと測定が開始します。カメラの前でずっと同じ手の形にして、そのままいろいろな角度から手の形をカメラに映しましょう。このデータづくりが、AIの精度に直接関係します。
測定を終わるときは、さきほどと同じ手段で停止させてください。測定が終わったら、hand_dataという名前のフォルダにデータが保存されているか確認してください。

prepare_data.py
import cv2                      # 画像処理用のライブラリ
import mediapipe as mp          # 手の検出とランドマーク推定用のライブラリ
import csv                      # CSV操作用ライブラリ
import numpy as np              # 数値計算用ライブラリ
from datetime import datetime   # 日時取得用ライブラリ

# 設定
CLASS_NAME = input("保存するクラス名(例: Rock, Scissors, Paper): ")
CSV_PATH = f"./hand_data/{CLASS_NAME}.csv"

mp_hands = mp.solutions.hands                                            # 手検出モジュールの初期化
hands = mp_hands.Hands(max_num_hands=1, min_detection_confidence=0.5)    # 手検出モデルの初期化(認識する手の数=1、最小検出信頼度=0.5)

# CSV初期化
with open(CSV_PATH, mode='w', newline='', encoding='utf-8') as f:
    writer = csv.writer(f)
    header = ["timestamp"] + [f"{axis}{i}" for i in range(21) for axis in ['x', 'y', 'z']]
    writer.writerow(header)


# メインループ
cap = cv2.VideoCapture(0)

while True:
    ret, frame = cap.read()
    if not ret:
        break

    frame = cv2.flip(frame, 1)
    rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
    results = hands.process(rgb)

    if results.multi_hand_landmarks:                                      # 手が検出された場合
        for hand_landmarks in results.multi_hand_landmarks:
            landmarks = np.array([[lm.x, lm.y, lm.z] for lm in hand_landmarks.landmark])

            # === 手首基準の相対座標化 ===
            wrist = landmarks[0]
            rel_landmarks = landmarks - wrist

            # === スケーリング(正規化) ===
            max_range = np.max(np.linalg.norm(rel_landmarks, axis=1))
            if max_range > 0:
                rel_landmarks /= max_range

            # === 保存 ===
            now = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
            row = [now] + rel_landmarks.flatten().tolist()

            with open(CSV_PATH, mode='a', newline='', encoding='utf-8') as f:
                writer = csv.writer(f)
                writer.writerow(row)

    cv2.imshow("Collect Hand Data", frame)
    if cv2.waitKey(1) & 0xFF == 27:
        break

cap.release()
cv2.destroyAllWindows()
print(f"データ収集終了: {CSV_PATH}")

測定が終わったら、3つのCSVをまとめてデータセットを作ります。以下のコードを実行してください。

create_dataset.py
import pandas as pd         # データ操作用ライブラリ
import numpy as np         # 数値計算用ライブラリ
import os                  # OS操作用ライブラリ
from glob import glob      # ファイルパス操作用ライブラリ

# 設定
input_dir = "hand_data"      # CSVフォルダ
output_path = "dataset.npy"  # 出力ファイル
skip_seconds = 5             # 最初の5秒をスキップ

# ランドマークデータを読み込み・前処理する関数
def load_and_preprocess_csv(file_path):
    df = pd.read_csv(file_path)

    # 最初の5秒スキップ
    start_time = pd.to_datetime(df["timestamp"].iloc[0])
    df["timestamp"] = pd.to_datetime(df["timestamp"])
    df = df[df["timestamp"] > start_time + pd.Timedelta(seconds=skip_seconds)]

    # ランドマーク座標を取得(小文字に対応)
    coords = df.filter(regex="(x|y|z)\d+").values
    if coords.size == 0:
        raise ValueError(f"❌ 座標データが見つかりません: {file_path}")

    normalized_frames = []
    for frame in coords.reshape(-1, 21, 3):
        wrist = frame[0]
        rel = frame - wrist  # 手首原点化

        # 1フレームごとにスケール正規化
        scale = np.linalg.norm(rel, axis=1).max()
        if scale > 0:
            rel /= scale

        normalized_frames.append(rel.flatten())

    return np.array(normalized_frames)

# メイン処理
files = glob(os.path.join(input_dir, "*.csv"))
if not files:
    raise FileNotFoundError(f"❌ CSVファイルが見つかりません: {input_dir}")

data_by_class = {}
for f in files:
    class_name = os.path.splitext(os.path.basename(f))[0]  # Rock, Paper, Scissors
    coords = load_and_preprocess_csv(f)
    if class_name not in data_by_class:
        data_by_class[class_name] = []
    data_by_class[class_name].append(coords)

# クラス間でデータ数を揃える
min_len = min(min(len(c) for c in data_by_class[k]) for k in data_by_class)
print(f"📏 各クラス {min_len} サンプルに統一")

X, y = [], []
for class_name, datasets in data_by_class.items():
    class_data = np.vstack([c[:min_len] for c in datasets])
    X.append(class_data)
    y.append(np.full(len(class_data), class_name))

X = np.vstack(X)
y = np.concatenate(y)

np.save(output_path, {"X": X, "y": y})
print(f"✅ dataset saved to {output_path}")
print(f"   X shape = {X.shape}, y shape = {y.shape}")
print(f"   classes = {list(data_by_class.keys())}")

このプログラムを実行することで、dataset.npyが作成されると思います。
次は、このデータセットを使って機械学習を行います。

4. Pytorchで学習データを使い機械学習を行い、AIをつくる

今回、機械学習で使う手法は、ニューラルネットワークと呼ばれるものです。ニューラルネットワークについて詳しく知りたい方は、以下を参考にしてみてください。
https://zenn.dev/nekoallergy/articles/ml-basic-nn01

早速ですが、以下のプログラムを動かして、学習させましょう。
実行すると、学習を開始します。ターミナル上にログが出力されると思います。accというのは、Accuracy(正解率)の略です。このAccuracyが高くなるようにモデルの定義であったり、学習率(learning_rate)を調整しています。学習が終わったのを確認できたら、次に行きましょう。

hand_train.py
import torch                                                              # PyTorchライブラリ
import torch.nn as nn                                                     # ニューラルネットワーク用ライブラリ
import torch.optim as optim                                               # 最適化アルゴリズム用ライブラリ
from torch.utils.data import TensorDataset, DataLoader, random_split      # データセット・データローダー用ライブラリ
import numpy as np                                                        # 数値計算用ライブラリ

# データ読み込み
data = np.load("dataset.npy", allow_pickle=True).item()
X, y = data["X"], data["y"]

# ラベルを数値に変換
labels = sorted(set(y))
label_to_idx = {l: i for i, l in enumerate(labels)}
idx_to_label = {i: l for l, i in label_to_idx.items()}
y = np.array([label_to_idx[v] for v in y])

# torch化
X = torch.tensor(X, dtype=torch.float32)
y = torch.tensor(y, dtype=torch.long)
dataset = TensorDataset(X, y)

# データ分割
train_size = int(len(dataset)*0.8)
train_set, val_set = random_split(dataset, [train_size, len(dataset)-train_size])
train_loader = DataLoader(train_set, batch_size=128, shuffle=True)
val_loader = DataLoader(val_set, batch_size=64)

# モデル定義
class HandClassifier(nn.Module):
    def __init__(self, input_size, hidden_size, num_classes):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(input_size, hidden_size),
            nn.ReLU(),
            nn.Linear(hidden_size, num_classes)
        )
    def forward(self, x):
        return self.net(x)

input_size = X.shape[1]
num_classes = len(labels)
model = HandClassifier(input_size, 128, num_classes)

criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=0.0001)

# 学習
prev_loss = None
for epoch in range(300):
    model.train()
    total_loss = 0
    for xb, yb in train_loader:
        optimizer.zero_grad()
        outputs = model(xb)
        loss = criterion(outputs, yb)
        loss.backward()
        optimizer.step()
        total_loss += loss.item()
    avg_loss = total_loss / len(train_loader)

    # 学習率調整
    if prev_loss is not None:
        if abs(prev_loss - avg_loss) < 1e-5 or avg_loss > prev_loss:
            lr = optimizer.param_groups[0]['lr'] * 0.5
            optimizer.param_groups[0]['lr'] = max(lr, 1e-6)
    prev_loss = avg_loss

    # 検証
    model.eval()
    correct, total = 0, 0
    with torch.no_grad():
        for xb, yb in val_loader:
            preds = torch.argmax(model(xb), dim=1)
            correct += (preds == yb).sum().item()
            total += yb.size(0)
    acc = correct / total
    print(f"Epoch {epoch+1}: loss={avg_loss:.6f}, val_acc={acc:.4f}")

# モデル保存
torch.save({
    "model_state_dict": model.state_dict(),
    "label_to_idx": label_to_idx,
    "idx_to_label": idx_to_label
}, "hand_classifier.pth")
print("✅ モデルを hand_classifier.pth に保存しました")

5. 作成できたAIを使って、リアルタイムで手の形を識別する

先ほど作ったAIを使って、リアルタイムで手の形を識別してみましょう
以下がプログラムです。

hand_ai.py
import cv2                     # 画像処理用のライブラリ
import mediapipe as mp         # 手の検出とランドマーク推定用のライブラリ
import torch                   # PyTorchライブラリ
import torch.nn as nn          # ニューラルネットワーク用ライブラリ
import numpy as np             # 数値計算用ライブラリ


# モデル定義(学習時と同じ構造)
class HandClassifier(nn.Module):
    def __init__(self, input_size, hidden_size, num_classes):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(input_size, hidden_size),
            nn.ReLU(),
            nn.Linear(hidden_size, num_classes)
        )
    def forward(self, x):
        return self.net(x)

# モデルロード
checkpoint = torch.load("hand_classifier.pth", map_location="cpu", weights_only=False)   # 学習済みモデル読み込み
idx_to_label = checkpoint["idx_to_label"]
input_size = 63        # 21ランドマーク×3座標
num_classes = len(idx_to_label)   # 3クラス(グー、チョキ、パー)

model = HandClassifier(input_size, 128, num_classes)
model.load_state_dict(checkpoint["model_state_dict"])
model.eval()


# Mediapipe設定
mp_hands = mp.solutions.hands
mp_drawing = mp.solutions.drawing_utils
hands = mp_hands.Hands(max_num_hands=1, min_detection_confidence=0.5)

cap = cv2.VideoCapture(0)

while True:
    ret, frame = cap.read()
    if not ret:
        break
    frame = cv2.flip(frame, 1)
    rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
    results = hands.process(rgb)

    if results.multi_hand_landmarks:
        for hand_landmarks in results.multi_hand_landmarks:
            mp_drawing.draw_landmarks(frame, hand_landmarks, mp_hands.HAND_CONNECTIONS)

            # ランドマーク取得
            coords = np.array([[lm.x, lm.y, lm.z] for lm in hand_landmarks.landmark])
            wrist = coords[0]
            rel = coords - wrist

            # 左手なら反転
            if hand_landmarks.landmark[17].x < hand_landmarks.landmark[5].x:
                rel[:, 0] *= -1

            # 正規化
            scale = np.linalg.norm(rel, axis=1).max()
            if scale > 0:
                rel /= scale

            # flatten
            inp = torch.tensor(rel.flatten(), dtype=torch.float32).unsqueeze(0)
            with torch.no_grad():
                pred = model(inp)
                label_idx = torch.argmax(pred, dim=1).item()
                label = idx_to_label[label_idx]

            cv2.putText(frame, label, (10, 50), cv2.FONT_HERSHEY_SIMPLEX,
                        1.5, (0, 0, 255), 2)

    cv2.imshow("Hand Gesture", frame)
    if cv2.waitKey(1) & 0xFF == 27:  # ESC
        break

cap.release()
cv2.destroyAllWindows()

これで、今回の目的である手の形の分類を行うことが出来ました。現在は、3クラス分類(グー、チョキ、パー)になっていますが、ほかのいろいろな形のデータも学習させることでもっとたくさんの分類に対応できるようになります。最後のプログラムをいじれば、じゃんけんに絶対に勝てるAIをつくることもできますよね?
試してみてください。

まとめ

長々となりましたが、これで終わりたいと思います。出力結果を見せることが出来なくてすみません。
プログラムの方は、まとめてgithubに上げてます。一応、私が両手で作った高精度なモデルも載せてます。良かったら、試してみてください。エラーがあったら、コメントで教えてもらえるとありがたいです。

https://github.com/tanileo/HandClassifierAI

Discussion

shuntashunta

本文を参考に手の識別(グー・チョキ・パー)を行うことができました。
他のクラスを作成して、手の識別に挑戦してみたいと思います。

タニレオタニレオ

コメントありがとうございます
是非、挑戦してみてください

TanileoouenTanileoouen

流石ですね。これまでいろいろなAIに触れてきましたが手の形の識別は思いつきもしませんでした。
自分もこれからこの記事を参考に作って、じゃんけん勝敗即判定プログラム作ります。