前に戻る

※ 最低限のメモのようなもので、資料としてちゃんと整えておりません。あくまで参考までに。。

準備物

  • M5StickC Plus2
  • PC

システム全体のイメージ

ジェスチャー入力でPCを操作(今回は魔法を唱える)

まずはChatGPTに相談

以下プロンプトを投げてみる

ジェスチャー認識アプリを作りたい
・ユーザはM5stickCplus2を持つ
・PCに向かって、三角形、四角形、丸、星をそれぞれ描くジェスチャをすると、それぞれに対応したエフェクトがPCに表示
・魔法でいいかな 三角形は炎、四角形は氷、丸は雷、丸は光系の魔法エフェクトが再生される
・通信はBluetoothで
・ジェスチャーはDTWで認識でいいかな。
・ボタンAを教えている間にジェスチャーを行う
・ジェスチャー中の加速度データをPCニソウシン
・PC側であらかじめ保存しておいた波形とDTWで比較する。
・一番近い動作のエフェクトを再生する
・DTW比較用のデータ収集モードも作って
・加速度のxyzのデータ使いましょうか
・fastDTWが楽かな
・それぞれの動作に対して、3回分のデータがあるといいかなxyz3波形x3回分で一つの図形的な。
・あまり動きが似ていない場合、距離がどれからも遠すぎる場合は魔法失敗みたいなのを表示させる
GPTの返答

まず全体像から固めて、そのあと M5側コード案PC側Python案(fastdtw+エフェクト表示) まで一気に叩き台出します。


1. 全体構成(ざっくり仕様)

  • ハード:
    • ユーザが M5StickC Plus2 を手に持つ(向きはある程度統一しておく)
  • 通信:
    • Bluetoothシリアル(M5 ↔ PC)
  • ジェスチャ:
    • 図形 4種類
      • 三角形 → 炎(FIRE)
      • 四角形 → 氷(ICE)
      • 丸 → 雷(THUNDER)
      • 星 → 光(LIGHT)
  • 操作フロー(通常プレイ):
    1. ユーザが M5 の ボタンAを押している間ジェスチャーを実行
    2. その間、M5が 時刻t, ax, ay, az をPCへストリーミング
    3. Aを離すと「END」送信 → PC側でデータ取得終了
    4. PC側で、事前に保存しておいた各図形のテンプレ波形と fastDTWで距離計算
    5. 一番距離が小さい図形を採用
      • ただし、距離が全部しきい値より大きい → 魔法失敗エフェクト
    6. 対応した魔法エフェクトをPC画面に表示
  • 学習(テンプレ収集モード):
    • PC側のスクリプトを 「学習モード」 で起動
    • コンソール指示:
      • 「三角形を3回、ボタンAを押しながら描いてください」…など
    • 各ジェスチャのサンプルを3回ずつ収集して .npz 等で保存

2. ジェスチャーデータの設計

2-1. 信号

  • 1サンプル = [t, ax, ay, az]
  • t: millis() をジェスチャ開始時からの相対時間にして送るのが楽
  • サンプリングレート:
    • だいたい 50Hz前後if (millis() - lastSampleMs > 20))でOK

2-2. DTWの入力

選択肢は2つ:

  1. 加速度の合成値のみ
    • mag = sqrt(ax^2 + ay^2 + az^2)
    • 1次元 DTW → 実装がシンプル
  2. 3次元のまま
    • fastdtw に距離関数 dist(x, y) = ||x - y||2 を渡す
    • 3D時系列の DTW(情報量多い)

ここでは「それなりにちゃんとやる」方として、3次元DTWにします。

  • Python側では、各サンプルを np.array([ax, ay, az]) にして、
  • fastdtw(seq1, seq2, dist=lambda x, y: np.linalg.norm(x-y))

2-3. テンプレ構造

PC側でこんな感じの構造を想定:

templates = {
    "triangle": [seq1, seq2, seq3],
    "square":   [seq1, seq2, seq3],
    "circle":   [seq1, seq2, seq3],
    "star":     [seq1, seq2, seq3],
}
  • seq は、長さNのリスト(もしくは np.ndarray)で、
    • 各要素が np.array([ax, ay, az])
  • 保存は np.savez("templates.npz", **{label: np.array(list_of_seq, dtype=object)}) などでOK

DTW距離の使い方:

  • ある入力 X に対して、
    • 各ラベル label の各テンプレ T との距離 d(X, T) を全部計算
    • ラベルごとに最小値 d_min_label = min_T d(X, T) を取る
    • その中で最も小さい d_best を持つラベルを採用
    • もし d_best > THRESHOLD → 魔法失敗

3. M5StickC Plus2 側コード案(Bluetooth+IMU)

役割を割り切るのがポイント
M5は「Aを押している間、加速度を垂れ流すだけ」。
テンプレかプレイかはPC側が決める

#include <M5StickCPlus2.h>

// サンプリング周期 [ms]
const uint32_t SAMPLE_INTERVAL_MS = 20;

bool recording = false;
uint32_t startMs = 0;
uint32_t lastSampleMs = 0;

void setup() {
  auto cfg = M5.config();
  M5.begin(cfg);
  
  // IMU初期化
  M5.Imu.begin();

  // ディスプレイ
  M5.Display.setRotation(1);
  M5.Display.setTextSize(2);
  M5.Display.fillScreen(BLACK);
  M5.Display.setCursor(0, 0);
  M5.Display.println("Gesture Wand");
  M5.Display.println("Hold Btn A");
  M5.Display.println("to cast");

  // シリアル(USB側/BT側は環境に応じて)
  // BluetoothSerial を使う場合は別途初期化が必要。
  // ここでは簡単のため USB シリアルと想定。
  Serial.begin(115200);
}

void startRecording() {
  recording = true;
  startMs = millis();
  lastSampleMs = 0;

  M5.Display.fillScreen(BLACK);
  M5.Display.setCursor(0, 0);
  M5.Display.println("Recording...");
  M5.Display.println("Draw gesture!");

  Serial.println("START");  // ジェスチャ開始マーカー
}

void stopRecording() {
  recording = false;

  M5.Display.fillScreen(BLACK);
  M5.Display.setCursor(0, 0);
  M5.Display.println("Done");
  M5.Display.println("Send to PC");

  Serial.println("END");    // ジェスチャ終了マーカー
}

void loop() {
  M5.update();

  // Aボタンが押された瞬間に録画開始
  if (!recording && M5.BtnA.wasPressed()) {
    startRecording();
  }

  // Aボタンを離したら録画終了
  if (recording && M5.BtnA.wasReleased()) {
    stopRecording();
  }

  if (recording) {
    uint32_t now = millis();
    uint32_t rel = now - startMs;
    if (now - startMs >= lastSampleMs + SAMPLE_INTERVAL_MS) {
      lastSampleMs = rel;

      float ax, ay, az;
      M5.Imu.getAccelData(&ax, &ay, &az);

      // t[ms], ax, ay, az をCSVで送る
      Serial.print(rel);
      Serial.print(",");
      Serial.print(ax, 6);
      Serial.print(",");
      Serial.print(ay, 6);
      Serial.print(",");
      Serial.println(az, 6);
    }
  }

  delay(1);
}

※ 実際に Bluetooth シリアル(M5側 BluetoothSerial SerialBT; など)でやるなら、その部分は置き換え。PC側は /dev/tty.* のBTポートを指定。


4. PC側 Python スクリプト骨格

4-1. 依存ライブラリ

pip install pyserial fastdtw pygame numpy

4-2. 共通:M5から1ジェスチャーを読む関数

import serial
import numpy as np

def read_gesture(ser):
    """
    M5から1ジェスチャー分のデータを受信して返す。
    戻り値:
        seq: [np.array([ax, ay, az]), ...] のリスト
    """
    # STARTマーカー待ち
    while True:
        line = ser.readline().decode('utf-8', errors='ignore').strip()
        if line == "START":
            break

    data = []
    while True:
        line = ser.readline().decode('utf-8', errors='ignore').strip()
        if not line:
            continue
        if line == "END":
            break

        try:
            t_str, ax_str, ay_str, az_str = line.split(",")
            ax = float(ax_str)
            ay = float(ay_str)
            az = float(az_str)
            data.append(np.array([ax, ay, az], dtype=np.float32))
        except ValueError:
            # 行が壊れていたら無視
            continue

    return data  # list of 3D vectors

5. 学習モード:テンプレ収集スクリプト例

# train_templates.py
import numpy as np
import serial
from pathlib import Path
from read_gesture_common import read_gesture  # 上の関数を別ファイルにしてimportしても良い

PORT = "/dev/tty.YOUR_BTM5_PORT"  # 要修正
BAUD = 115200

LABELS = ["triangle", "square", "circle", "star"]
N_SAMPLES_PER_LABEL = 3

def main():
    ser = serial.Serial(PORT, BAUD, timeout=1)
    templates = {}

    for label in LABELS:
        samples = []
        for i in range(N_SAMPLES_PER_LABEL):
            input(f"[{label}] {i+1}/{N_SAMPLES_PER_LABEL} : M5のAを押しながらジェスチャーして Enter → ")
            seq = read_gesture(ser)
            print(f"  -> {len(seq)} samples")
            samples.append(np.array(seq, dtype=object))  # 長さがバラバラなので object

        templates[label] = np.array(samples, dtype=object)

    # 保存
    out_path = Path("gesture_templates.npz")
    np.savez(out_path, **templates)
    print(f"Saved templates to {out_path}")

if __name__ == "__main__":
    main()

6. 認識+エフェクト表示スクリプト案

6-1. DTW距離計算

# gesture_recognizer.py
import numpy as np
import serial
from fastdtw import fastdtw
from scipy.spatial.distance import euclidean  # 或いは np.linalg.norm でも可
import pygame
from read_gesture_common import read_gesture

PORT = "/dev/tty.YOUR_BTM5_PORT"  # 要修正
BAUD = 115200
TEMPLATE_FILE = "gesture_templates.npz"

# しきい値(要チューニング)
# 実データで距離をprintしながら見て決める
DIST_THRESHOLD = 20.0  # 仮の値。絶対にここは後で調整が必要

def load_templates():
    data = np.load(TEMPLATE_FILE, allow_pickle=True)
    templates = {}
    for label in data.files:
        templates[label] = list(data[label])  # [seq1, seq2, ...]
    return templates

def seq_distance(seq1, seq2):
    """3次元ベクトル列どうしのDTW距離"""
    # fastdtw に渡すため、Pythonのリストにしておく
    s1 = [np.array(v) for v in seq1]
    s2 = [np.array(v) for v in seq2]
    distance, _ = fastdtw(s1, s2, dist=lambda x, y: np.linalg.norm(x - y))
    return distance

def classify_gesture(seq, templates):
    best_label = None
    best_dist = float("inf")

    for label, templ_list in templates.items():
        # 各テンプレとの距離を計算して最小値を使う
        d_list = [seq_distance(seq, templ) for templ in templ_list]
        d_min = min(d_list)
        print(f"{label}: {d_list} -> min {d_min:.2f}")
        if d_min < best_dist:
            best_dist = d_min
            best_label = label

    if best_dist > DIST_THRESHOLD:
        return None, best_dist  # 魔法失敗
    return best_label, best_dist

6-2. Pygameでざっくり魔法エフェクト表示

ここでは「それっぽい全画面カラー+文字」の簡易版にしておきます。
(後でパーティクルや画像差し替えすればよい)

def init_pygame():
    pygame.init()
    screen = pygame.display.set_mode((800, 600))
    pygame.display.set_caption("Gesture Magic")
    return screen

def draw_effect(screen, label):
    # pygameイベントを消化してウィンドウフリーズを防ぐ
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            pygame.quit()
            raise SystemExit

    if label == "triangle":
        color = (255, 80, 0)   # 火
        text = "FIRE"
    elif label == "square":
        color = (150, 220, 255) # 氷
        text = "ICE"
    elif label == "circle":
        color = (255, 255, 0)  # 雷
        text = "THUNDER"
    elif label == "star":
        color = (255, 255, 255) # 光
        text = "LIGHT"
    else:
        color = (50, 50, 50)
        text = "FAILED"

    screen.fill(color)
    font = pygame.font.SysFont(None, 120, bold=True)
    text_surf = font.render(text, True, (0, 0, 0))
    rect = text_surf.get_rect(center=(400, 300))
    screen.blit(text_surf, rect)
    pygame.display.flip()

6-3. メインループ

def main():
    templates = load_templates()
    ser = serial.Serial(PORT, BAUD, timeout=1)

    screen = init_pygame()
    clock = pygame.time.Clock()

    print("Ready. 振りたいときに、M5のAボタンを押しながらジェスチャーしてね。")

    while True:
        # ウィンドウイベント処理
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                pygame.quit()
                return

        # 1ジェスチャー受信
        print("Waiting for gesture...")
        seq = read_gesture(ser)
        if len(seq) < 5:
            print("Too short gesture, ignored")
            continue

        label, dist = classify_gesture(seq, templates)
        if label is None:
            print(f"Magic failed (dist={dist:.2f})")
            draw_effect(screen, None)
        else:
            print(f"Recognized: {label}  (dist={dist:.2f})")
            draw_effect(screen, label)

        # しばらくエフェクトを見せる
        timer = 0
        while timer < 1000:  # 1000ms = 1秒
            for event in pygame.event.get():
                if event.type == pygame.QUIT:
                    pygame.quit()
                    return
            clock.tick(60)
            timer += clock.get_time()

if __name__ == "__main__":
    main()

7. どこが「要調整ポイント」か(ハードモード視点)

ちゃんと動かそうとすると、以下は避けて通れません:

  1. DIST_THRESHOLD のチューニング
    • 「良い例/悪い例」をいくつか集めて、距離分布を見ないと決まりません。
    • ログを眺めて、
      • 同じ動き:10〜30
      • 全然違う動き:60〜100
        みたいな感じが見えたら、その中間あたりを置くイメージ。
  2. ユーザごと・持ち方ごとの差
    • 手首に固定するのか、手で握るのかで波形が変わる。
    • 授業で使うなら「持ち方をちゃんと指定」しないと精度ガタ落ちします。
  3. ジェスチャの似すぎ問題
    • 三角形と四角形が「ただのぐるっとひと回し」になってしまうと判別がつかない。
    • 意図的に「三角形は小さく素早く」「四角形は大きくゆっくり」とか、動きの特徴を言語化して指示した方が良い。
  4. Bluetoothレイテンシ
    • サンプリング間隔を詰めすぎると、送信が追いつかない可能性あり。
    • 授業用なら 30〜50Hz くらいで十分。

8. 次のステップ提案

  1. まずは USBシリアル で動作確認して、波形ログ(CSV)を保存してみる
    → Jupyter / Excelで波形を眺めると、ジェスチャの違いの「見た目」が掴める。
  2. その次に fastdtw+テンプレ比較 部分だけを先に回して、距離のレンジを確認
  3. 閾値が見えたら、最後に Pygameのエフェクト+Bluetooth化 をまとめて入れる

この構成で一式動かすところまでいければ、
あとは「魔法エフェクト部分を派手に」「星の光は残像付き」みたいな演出側の遊びに集中できます。

もし「とりあえず最小構成でいいから、学習なし・テンプレは固定配列で埋め込みたい」みたいな割り切り版にしたいなら、その方向の簡略版も出せます。

上記回答を参考に1ステップ試しつつ、おかしいところは都度聞きながら実装を進めていきます。

M5StickC Plus2 → PC (Bluetooth Serial) で加速度を送る完成コード

  • ボタンAを押している間だけ加速度ストリーミング
  • ボタンを押した瞬間に START
  • 離した瞬間に END
  • データは t, ax, ay, az の CSV
  • PC は /dev/tty.M5StickCPlus2-ESP32SPP などで受信

🔽 完成コード(Bluetooth版 + コメント大量)

#include <M5StickCPlus2.h>
#include "BluetoothSerial.h"   // Bluetooth シリアル通信用

BluetoothSerial SerialBT;      // Bluetooth シリアルオブジェクト

// サンプリング周期 [ms]
const uint32_t SAMPLE_INTERVAL_MS = 20;

// 録画(記録)状態フラグ
bool recording = false;

// 開始時刻(ジェスチャー開始基準時刻)
uint32_t startMs = 0;

// 前回送信したサンプル時刻(相対時間)
uint32_t lastSampleMs = 0;

void setup() {
  // M5StickC Plus2 初期化
  auto cfg = M5.config();
  M5.begin(cfg);

  // IMU 初期化(加速度など)
  M5.Imu.begin();

  // 画面設定
  M5.Display.setRotation(1);
  M5.Display.setTextSize(2);
  M5.Display.fillScreen(BLACK);
  M5.Display.setCursor(0, 0);
  M5.Display.println("Gesture Wand");
  M5.Display.println("Hold Btn A");
  M5.Display.println("to cast");

  // Bluetooth 初期化(デバイス名を設定)
  // → PC側では "GestureWand" として表示される
  SerialBT.begin("GestureWand");

  // あえてUSBシリアルも残す (デバッグ用)
  Serial.begin(115200);
  Serial.println("Bluetooth Ready!");
}

// 録画開始処理
void startRecording() {
  recording = true;
  startMs = millis();
  lastSampleMs = 0;

  // 画面表示
  M5.Display.fillScreen(BLACK);
  M5.Display.setCursor(0, 0);
  M5.Display.println("Recording...");
  M5.Display.println("Draw gesture!");

  // PC に記録開始を通知(Bluetooth)
  SerialBT.println("START");

  // USB デバッグにも出す
  Serial.println("[DEBUG] START");
}

// 録画終了処理
void stopRecording() {
  recording = false;

  M5.Display.fillScreen(BLACK);
  M5.Display.setCursor(0, 0);
  M5.Display.println("Done");
  M5.Display.println("Send to PC");

  // PC に記録終了を通知
  SerialBT.println("END");

  // デバッグ
  Serial.println("[DEBUG] END");
}

void loop() {
  M5.update();

  // Aボタンが押された瞬間:録画開始
  if (!recording && M5.BtnA.wasPressed()) {
    startRecording();
  }

  // Aボタンを離した瞬間:録画終了
  if (recording && M5.BtnA.wasReleased()) {
    stopRecording();
  }

  // 録画中:一定周期で加速度を送信
  if (recording) {
    uint32_t now = millis();        // 現在時刻
    uint32_t rel = now - startMs;   // ジェスチャー開始からの相対時間

    // 前回サンプルから一定時間経ったか
    if (rel >= lastSampleMs + SAMPLE_INTERVAL_MS) {
      lastSampleMs = rel;

      float ax, ay, az;
      M5.Imu.getAccelData(&ax, &ay, &az);

      // CSV 形式で Bluetooth に送信
      SerialBT.print(rel);
      SerialBT.print(",");
      SerialBT.print(ax, 6);
      SerialBT.print(",");
      SerialBT.print(ay, 6);
      SerialBT.print(",");
      SerialBT.println(az, 6);

      // デバッグ用(必要なければコメントアウト)
      Serial.print("[DBG] ");
      Serial.print(rel);
      Serial.print(" ");
      Serial.print(ax);
      Serial.print(" ");
      Serial.print(ay);
      Serial.print(" ");
      Serial.println(az);
    }
  }

  delay(1);  // 少し休ませる
}

🔥 これでできること

操作Bluetooth に送られる内容
ボタンAを押した瞬間START
押している間t, ax, ay, az のストリーム
離した瞬間END

🔧 PC 側のポート確認

macOS ならターミナルで:

ls /dev/tty.*

/dev/tty.GestureWand-ESP32SPP
が出てくれば OK。

「テンプレ収集モード用:Bluetoothでジェスチャを受信してCSV保存」まで


Step 0. 事前準備(1回だけやればOK)

  1. ライブラリインストール
pip install pyserial
  1. ポート名を確認

M5 を Bluetooth で繋いだ状態で:

ls /dev/tty.*

/dev/tty.GestureWand-ESP32SPP
みたいな名前のデバイスが出てくる想定(さっきの M5 コードの SerialBT.begin("GestureWand"); に対応)。

それをこのあとの PORT にコピペ。


Step 1. 「テンプレ収集モード」Pythonスクリプト

やることはシンプル:

  • ラベル(triangle, square, circle, star)ごとに
    3回ずつジェスチャを取る
  • 1回分のデータを 1つの CSV に保存
    • data/triangle_1.csv
    • data/triangle_2.csv
    • data/triangle_3.csv
    • …という感じで保存
  • 形式は: t_ms,ax,ay,az 0, 0.01, 0.02, 0.98 20, 0.03, 0.01, 0.97 ...

📄 collect_templates.py(丸ごとコピペでOK)

import serial
import csv
from pathlib import Path

# ==== ここを自分の環境に合わせて書き換える ====
# ls /dev/tty.* で調べた Bluetooth シリアルポート名
PORT = "/dev/tty.GestureWand-ESP32SPP"  # 例:macOS の場合
BAUDRATE = 115200
# ============================================

# 収集するジェスチャラベル
LABELS = ["triangle", "square", "circle", "star"]

# 各ラベルにつき何回サンプルを取るか
SAMPLES_PER_LABEL = 3

# ジェスチャが短すぎる場合に警告するための最小サンプル数
MIN_SAMPLES = 5


def read_one_gesture(ser: serial.Serial):
    """
    M5 から 1 回分のジェスチャデータを受信する関数。

    プロトコル:
        - "START" が来るまで待つ
        - その後、"END" が来るまで
          "t,ax,ay,az" の CSV 行を読み取り続ける

    戻り値:
        samples: list of (t_ms:int, ax:float, ay:float, az:float)
    """
    print("  -> Waiting for START from M5...")

    # "START" マーカーが来るまでひたすら読む
    while True:
        line = ser.readline().decode("utf-8", errors="ignore").strip()
        if not line:
            continue
        # デバッグしたいときは以下を一時的に有効化
        # print(f"[DBG] recv: {line}")
        if line == "START":
            print("  -> START received")
            break

    samples = []

    # "END" までセンサ値を読み続ける
    while True:
        line = ser.readline().decode("utf-8", errors="ignore").strip()
        if not line:
            continue

        if line == "END":
            print("  -> END received")
            break

        # t,ax,ay,az をパース
        try:
            t_str, ax_str, ay_str, az_str = line.split(",")
            t = int(t_str)
            ax = float(ax_str)
            ay = float(ay_str)
            az = float(az_str)
            samples.append((t, ax, ay, az))
        except ValueError:
            # 想定外の行は無視(ノイズ対策)
            print(f"  !! invalid line, skipped: {line}")
            continue

    return samples


def save_gesture_to_csv(samples, filepath: Path):
    """
    1 回分のジェスチャデータを CSV として保存する。

    samples:
        list of (t_ms:int, ax:float, ay:float, az:float)
    filepath:
        Path オブジェクト
    """
    filepath.parent.mkdir(parents=True, exist_ok=True)

    with filepath.open("w", newline="") as f:
        writer = csv.writer(f)
        # ヘッダ
        writer.writerow(["t_ms", "ax", "ay", "az"])
        # 本文
        for t, ax, ay, az in samples:
            writer.writerow([t, ax, ay, az])


def main():
    print("=== Gesture Template Collector (Bluetooth) ===")
    print(f"Opening serial port: {PORT} @ {BAUDRATE}")
    ser = serial.Serial(PORT, BAUDRATE, timeout=1)

    print("接続しました。")
    print("M5 側の電源が入っていて、Bluetooth がつながっていることを確認してください。\n")

    for label in LABELS:
        print(f"\n=== Label: {label} ===")

        for i in range(1, SAMPLES_PER_LABEL + 1):
            print(f"\n[{label}] {i}/{SAMPLES_PER_LABEL} 回目の収集")
            print("1) M5 を手に持つ")
            print("2) Enter を押したら、ボタン A を押しながらその図形ジェスチャを描く")
            input("   準備ができたら Enter を押してください... ")

            samples = read_one_gesture(ser)
            n = len(samples)
            print(f"  -> {n} samples received.")

            if n < MIN_SAMPLES:
                print("  !! WARNING: サンプル数が少なすぎます。もう一度やり直した方がよいかも。")

            # ファイル名: data/triangle_1.csv など
            filepath = Path("data") / f"{label}_{i}.csv"
            save_gesture_to_csv(samples, filepath)
            print(f"  -> Saved to {filepath.resolve()}")

    print("\nすべてのテンプレ収集が完了しました。")
    print("data/ 以下に CSV ファイルが作成されています。")
    ser.close()


if __name__ == "__main__":
    main()

Step 2. 動かし方

  1. 上のコードを collect_templates.py として保存。
  2. M5 にさっきの Bluetooth 版スケッチを書き込む。
  3. M5 の電源オン → PC と Bluetooth ペアリング(最初だけ)。
  4. PORT を正しいデバイス名に直す。
  5. ターミナルで:
python collect_templates.py
  1. 指示に従って:
    • ラベルごとに
    • 「Enter を押す → ボタンAを押しながら三角形/四角形/丸/星を描く」
    • を3回繰り返す。

終わると、data/ フォルダに

  • triangle_1.csv
  • triangle_2.csv
  • triangle_3.csv
  • square_1.csv
    などが揃うはず。

「DTW で認識するリアルタイム判定スクリプト」 を作る

  • data/triangle_1.csv みたいな テンプレ CSV を読み込む
  • M5 から Bluetooth で 1 ジェスチャー受信
  • fastDTW で距離を計算 → 一番近い図形を選ぶ
  • 距離が全部しきい値より大きければ 魔法失敗

って流れです。


インストール確認

fastdtw がまだなら:

pip install fastdtw numpy

(前回の pyserial はもう入ってる前提)


認識スクリプト:recognize_gesture.py

下のコードを そのまま recognize_gesture.py として保存 して OK。
ポート名だけ自分の環境に合わせて変更して。

import serial
import csv
from pathlib import Path
from fastdtw import fastdtw
import numpy as np

# ==== ここを自分の環境に合わせて書き換える ====
PORT = "/dev/tty.GestureWand-ESP32SPP"  # ls /dev/tty.* で調べた Bluetooth ポート名
BAUDRATE = 115200
# ============================================

# テンプレがあるディレクトリ
DATA_DIR = Path("data")

# 収集済みテンプレの設定(collect_templates.py と揃える)
LABELS = ["triangle", "square", "circle", "star"]
SAMPLES_PER_LABEL = 3

# ジェスチャが短すぎるときに無視するためのサンプル数下限
MIN_SAMPLES = 5

# DTW 距離のしきい値(仮) → 実データ見ながらあとで調整必須
DIST_THRESHOLD = 50.0  # 大きすぎ/小さすぎと思ったら print を見ながら変える


# ================== 共通ユーティリティ ==================

def read_one_gesture(ser: serial.Serial):
    """
    M5 から 1 回分のジェスチャデータを受信する。

    プロトコル:
        - "START" が来るまで待つ
        - その後 "END" が来るまで
          "t,ax,ay,az" の CSV 行を読み取り続ける

    戻り値:
        seq: [np.array([ax, ay, az]), ...] のリスト
    """
    print("\n[INFO] Waiting for START from M5...")

    # "START" マーカー待ち
    while True:
        line = ser.readline().decode("utf-8", errors="ignore").strip()
        if not line:
            continue
        if line == "START":
            print("[INFO] START received")
            break

    samples = []

    # "END" まで読み込み
    while True:
        line = ser.readline().decode("utf-8", errors="ignore").strip()
        if not line:
            continue

        if line == "END":
            print("[INFO] END received")
            break

        try:
            t_str, ax_str, ay_str, az_str = line.split(",")
            ax = float(ax_str)
            ay = float(ay_str)
            az = float(az_str)
            samples.append(np.array([ax, ay, az], dtype=np.float32))
        except ValueError:
            print(f"[WARN] invalid line skipped: {line}")
            continue

    return samples


def load_templates():
    """
    data/ 以下の CSV を元にテンプレートを読み込む。

    想定ファイル名:
        data/triangle_1.csv
        data/triangle_2.csv
        ...
        data/star_3.csv

    戻り値:
        templates: dict[str, list[list[np.array([ax, ay, az])]]]
            例: {
                "triangle": [seq1, seq2, seq3],
                "square":   [seq1, seq2, seq3],
                ...
            }
    """
    templates = {}

    for label in LABELS:
        templ_list = []
        for i in range(1, SAMPLES_PER_LABEL + 1):
            path = DATA_DIR / f"{label}_{i}.csv"
            if not path.exists():
                print(f"[WARN] template file not found: {path}")
                continue

            seq = []
            with path.open("r") as f:
                reader = csv.reader(f)
                # 1 行目はヘッダ想定
                header = next(reader, None)
                for row in reader:
                    if len(row) < 4:
                        continue
                    try:
                        # t_ms = int(row[0])  # 使わないのでパースしなくてもよい
                        ax = float(row[1])
                        ay = float(row[2])
                        az = float(row[3])
                        seq.append(np.array([ax, ay, az], dtype=np.float32))
                    except ValueError:
                        continue

            if len(seq) == 0:
                print(f"[WARN] empty template: {path}")
            else:
                print(f"[INFO] loaded template {path} (len={len(seq)})")
                templ_list.append(seq)

        if not templ_list:
            print(f"[ERROR] no templates loaded for label '{label}'")
        templates[label] = templ_list

    return templates


def dtw_distance(seq1, seq2):
    """
    3 次元ベクトル列どうしの DTW 距離を fastdtw で計算する。
    """
    # fastdtw は Python のリストを想定しているのでそのまま渡す
    distance, _ = fastdtw(seq1, seq2, dist=lambda x, y: np.linalg.norm(x - y))
    return distance


def classify_gesture(seq, templates):
    """
    入力シーケンス seq に対して、テンプレ群 templates と DTW 距離を比較し、
    一番距離の小さいラベルを返す。

    戻り値:
        (best_label, best_dist)
        best_label: None の場合は「魔法失敗」と判定
    """
    if len(seq) < MIN_SAMPLES:
        print(f"[WARN] gesture too short (len={len(seq)}) -> 魔法失敗扱い")
        return None, float("inf")

    best_label = None
    best_dist = float("inf")

    print("[INFO] --- DTW distances ---")
    for label, templ_list in templates.items():
        if not templ_list:
            continue

        d_list = []
        for templ in templ_list:
            d = dtw_distance(seq, templ)
            d_list.append(d)

        d_min = min(d_list)
        print(f"  {label:8s}: {', '.join(f'{d:.1f}' for d in d_list)}  -> min {d_min:.1f}")

        if d_min < best_dist:
            best_dist = d_min
            best_label = label

    # しきい値より大きければ「魔法失敗」
    if best_dist > DIST_THRESHOLD:
        print(f"[INFO] best_dist={best_dist:.1f} > threshold={DIST_THRESHOLD:.1f} -> 魔法失敗")
        return None, best_dist

    print(f"[INFO] Recognized as '{best_label}' (dist={best_dist:.1f})")
    return best_label, best_dist


# ================== メイン処理 ==================

def main():
    print("=== Gesture Recognizer (DTW) ===")
    print(f"[INFO] loading templates from {DATA_DIR.resolve()}")
    templates = load_templates()

    print(f"[INFO] opening serial port: {PORT} @ {BAUDRATE}")
    ser = serial.Serial(PORT, BAUDRATE, timeout=1)

    print("\n準備完了。")
    print("M5 を手に持って、")
    print("  - ボタン A を押しながらジェスチャーを描く")
    print("  - 離すと自動で認識が走る")
    print("という流れで試せます。Ctrl+C で終了。\n")

    try:
        while True:
            # 1 ジェスチャー分読み込む
            seq = read_one_gesture(ser)

            # DTW で分類
            label, dist = classify_gesture(seq, templates)

            # 結果表示
            if label is None:
                print(">>> ✖ 魔法失敗! (No matching gesture)\n")
            else:
                if label == "triangle":
                    effect = "🔥 FIRE"
                elif label == "square":
                    effect = "❄ ICE"
                elif label == "circle":
                    effect = "⚡ THUNDER"
                elif label == "star":
                    effect = "✨ LIGHT"
                else:
                    effect = label

                print(f">>> ✅ 魔法発動: {label} -> {effect}\n")

    except KeyboardInterrupt:
        print("\n[INFO] interrupted by user")

    finally:
        ser.close()
        print("[INFO] serial closed")


if __name__ == "__main__":
    main()

どうやってチューニングするか

  1. python recognize_gesture.py を実行
  2. 各図形を「それっぽく」3〜5回くらい試してみる
  3. コンソールに
[INFO] --- DTW distances ---
  triangle: 12.3, 15.8, 14.1  -> min 12.3
  square  : 40.5, 38.9, 42.0  -> min 38.9
  circle  : 55.0, 50.1, 49.8  -> min 49.8
  star    : 60.2, 58.7, 62.0  -> min 58.7
[INFO] Recognized as 'triangle' (dist=12.3)
>>> ✅ 魔法発動: triangle -> 🔥 FIRE

みたいなログが出るはずなので、

  • 正解の図形のとき → best_dist がどのくらいか
  • ぜんぜん違う動きのとき → best_dist がどのくらいか

をざっと見てから、

DIST_THRESHOLD = 50.0

を上下させて「ここより上は魔法失敗」にするといい。

Bluetooth受信 + DTW認識 + Pygameで魔法エフェクト表示

magic_gesture.py(完全版・コメント多め)

import serial
import csv
from pathlib import Path
from fastdtw import fastdtw
import numpy as np
import pygame
import sys
import random
import math

# ==== ここを自分の環境に合わせて書き換える ====
PORT = "/dev/tty.GestureWand"  # ls /dev/tty.* で調べた Bluetooth シリアルポート名
BAUDRATE = 115200
# ============================================

DATA_DIR = Path("data")
LABELS = ["triangle", "square", "circle", "star"]
SAMPLES_PER_LABEL = 3
MIN_SAMPLES = 5
DIST_THRESHOLD = 90.0
EFFECT_DURATION_MS = 1500


# ================== Pygame ユーティリティ ==================

def pump_pygame_or_quit():
    """ウィンドウが固まらないようにイベントだけ流しておく"""
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            pygame.quit()
            sys.exit(0)


# ================== エフェクト描画 ==================

def show_effect(screen, label):
    """
    認識結果に応じて、魔法エフェクトを表示する。
    label:
      triangle -> FIRE(炎のパーティクル)
      square   -> ICE (氷結クリスタル)
      circle   -> THUNDER(雷)
      star     -> LIGHT(魔法陣)
      その他   -> FAIL(暴発)
    """
    w, h = screen.get_size()
    cx, cy = w // 2, h // 2
    clock = pygame.time.Clock()

    # ------------------------
    # 🔥 FIRE(triangle)
    # ------------------------
    if label == "triangle":
        bg = (10, 0, 0)
        base_color = (255, 120, 0)
        gravity = -0.03  # 上に舞い上がる
        spawn_rate = 40  # 一度に出す粒子数

        particles = []
        elapsed = 0

        while elapsed < EFFECT_DURATION_MS:
            pump_pygame_or_quit()
            dt = clock.tick(60)
            elapsed += dt

            screen.fill(bg)

            # 粒子を追加(炎の根元から)
            for _ in range(spawn_rate):
                vx = random.uniform(-0.8, 0.8)
                vy = random.uniform(-3.0, -1.0)
                particles.append({
                    "x": cx + random.uniform(-20, 20),
                    "y": cy + 60 + random.uniform(-10, 10),
                    "vx": vx,
                    "vy": vy,
                    "life": random.randint(25, 60),
                    "size": random.uniform(4, 10),
                    "color": (
                        max(0, min(255, base_color[0] + random.randint(-30, 30))),
                        max(0, min(255, base_color[1] + random.randint(-30, 30))),
                        max(0, min(255, base_color[2] + random.randint(-30, 30))),
                    ),
                })

            # 粒子更新 & 描画
            new_particles = []
            for p in particles:
                p["x"] += p["vx"]
                p["y"] += p["vy"]
                p["vy"] += gravity
                p["life"] -= 1

                if p["life"] > 0:
                    new_particles.append(p)
                    pygame.draw.circle(
                        screen,
                        p["color"],
                        (int(p["x"]), int(p["y"])),
                        int(p["size"])
                    )

            particles = new_particles

            # 炎の「柱」っぽい半透明レイヤ(上に行くほど細く)
            for i in range(5):
                alpha_factor = (5 - i) / 5.0
                rect_width = 80 * alpha_factor
                rect_height = 120 * alpha_factor
                flame_rect = pygame.Rect(0, 0, rect_width, rect_height)
                flame_rect.center = (cx, cy + 40 - i * 15)
                pygame.draw.ellipse(screen, (180, 60, 0), flame_rect, 2)

            pygame.display.flip()
        return

    # ------------------------
    # ❄ ICE(square)
    # ------------------------
    if label == "square":
        bg = (0, 25, 50)
        elapsed = 0

        while elapsed < EFFECT_DURATION_MS:
            pump_pygame_or_quit()
            dt = clock.tick(60)
            elapsed += dt

            screen.fill(bg)

            # 結晶サイズ
            t = elapsed / EFFECT_DURATION_MS
            scale = 0.5 + t * 1.8
            radius = 40 * scale

            # 六角形の氷結晶
            hex_points = []
            for i in range(6):
                angle = math.radians(60 * i - 90)
                x = cx + radius * math.cos(angle)
                y = cy + radius * math.sin(angle)
                hex_points.append((x, y))
            pygame.draw.polygon(screen, (200, 240, 255), hex_points, 3)

            # 放射状の氷柱
            length = 40 + t * 120
            for i in range(6):
                angle = math.radians(60 * i)
                x2 = cx + length * math.cos(angle)
                y2 = cy + length * math.sin(angle)
                pygame.draw.line(screen, (150, 220, 255), (cx, cy), (x2, y2), 5)

            # 小さい結晶をランダムに散らす
            for _ in range(15):
                rx = cx + random.randint(-200, 200)
                ry = cy + random.randint(-150, 150)
                pygame.draw.circle(screen, (180, 220, 255), (rx, ry), 2)

            pygame.display.flip()
        return

    # ------------------------
    # ⚡ THUNDER(circle)
    # ------------------------
    if label == "circle":
        bg = (20, 20, 0)
        elapsed = 0

        while elapsed < EFFECT_DURATION_MS:
            pump_pygame_or_quit()
            dt = clock.tick(60)
            elapsed += dt

            # フラッシュ(一定周期で背景を明るく)
            flash = (elapsed % 200 < 60)
            screen.fill((255, 255, 200) if flash else bg)

            # 稲妻のメインストローク
            points = []
            x = cx
            y = cy - 220
            points.append((x, y))

            for _ in range(12):
                x += random.randint(-30, 30)
                y += random.randint(20, 40)
                points.append((x, y))

            pygame.draw.lines(screen, (255, 255, 255), False, points, 6)

            # 枝分かれ
            for _ in range(6):
                idx = random.randint(2, len(points) - 2)
                bx, by = points[idx]
                bx2 = bx + random.randint(-50, 50)
                by2 = by + random.randint(10, 80)
                pygame.draw.line(screen, (255, 255, 180), (bx, by), (bx2, by2), 3)

            pygame.display.flip()
        return

    # ------------------------
    # ✨ LIGHT(star)
    # ------------------------
    if label == "star":
        bg = (30, 30, 30)
        elapsed = 0
        angle = 0

        while elapsed < EFFECT_DURATION_MS:
            pump_pygame_or_quit()
            dt = clock.tick(60)
            elapsed += dt
            angle += 2  # 回転

            screen.fill(bg)

            # 外円
            pygame.draw.circle(screen, (255, 255, 200), (cx, cy), 180, 3)

            # 内側の円
            pygame.draw.circle(screen, (200, 200, 255), (cx, cy), 110, 1)

            # 五芒星(回転)
            star = []
            for i in range(5):
                rad = math.radians(72 * i + angle - 90)
                x = cx + math.cos(rad) * 130
                y = cy + math.sin(rad) * 130
                star.append((x, y))
            pygame.draw.lines(screen, (255, 255, 160), True, star, 3)

            # 内側の回転三角形
            tri = []
            for i in range(3):
                rad = math.radians(120 * i - angle * 1.5)
                x = cx + math.cos(rad) * 60
                y = cy + math.sin(rad) * 60
                tri.append((x, y))
            pygame.draw.polygon(screen, (255, 200, 200), tri, 2)

            # 放射光線
            for a in range(0, 360, 20):
                rad = math.radians(a + angle * 1.5)
                x1 = cx + 30 * math.cos(rad)
                y1 = cy + 30 * math.sin(rad)
                x2 = cx + 200 * math.cos(rad)
                y2 = cy + 200 * math.sin(rad)
                pygame.draw.line(screen, (255, 255, 150), (x1, y1), (x2, y2), 2)

            pygame.display.flip()
        return

    # ------------------------
    # ❌ FAIL(魔法失敗)
    # ------------------------
    bg = (0, 0, 0)
    elapsed = 0

    while elapsed < EFFECT_DURATION_MS:
        pump_pygame_or_quit()
        dt = clock.tick(60)
        elapsed += dt

        # 不穏な赤いパルス背景
        pulse = int(80 + 40 * math.sin(elapsed * 0.02))
        screen.fill((pulse, 0, 0))

        # 中央の暴発コア
        r = 80 + int(10 * math.sin(elapsed * 0.1))
        pygame.draw.circle(screen, (0, 0, 0), (cx, cy), r)
        pygame.draw.circle(screen, (255, 40, 40), (cx, cy), 50, 5)

        pygame.display.flip()


# ================== シリアル & DTW ==================

def read_one_gesture(ser: serial.Serial):
    """
    M5 から 1 回分のジェスチャデータを受信する。

    プロトコル:
      - "START" 行が来るまで待つ
      - その後 "END" 行が来るまで "t,ax,ay,az" 行を読み続ける

    戻り値:
      seq: [np.array([ax, ay, az]), ...]
    """
    print("\n[INFO] Waiting for START from M5...")

    # START を待つ
    while True:
        pump_pygame_or_quit()
        line = ser.readline().decode("utf-8", errors="ignore").strip()
        if not line:
            continue
        if line == "START":
            print("[INFO] START received")
            break

    samples = []

    # END まで読む
    while True:
        pump_pygame_or_quit()
        line = ser.readline().decode("utf-8", errors="ignore").strip()
        if not line:
            continue

        if line == "END":
            print("[INFO] END received")
            break

        try:
            t_str, ax_str, ay_str, az_str = line.split(",")
            ax = float(ax_str)
            ay = float(ay_str)
            az = float(az_str)
            samples.append(np.array([ax, ay, az], dtype=np.float32))
        except ValueError:
            print(f"[WARN] invalid line skipped: {line}")
            continue

    print(f"[INFO] gesture length: {len(samples)} samples")
    return samples


def load_templates():
    """
    data/ 以下の CSV をテンプレとして読み込む。
    戻り値:
      templates: { label: [seq1, seq2, ...] }
    """
    templates = {}

    for label in LABELS:
        templ_list = []
        for i in range(1, SAMPLES_PER_LABEL + 1):
            path = DATA_DIR / f"{label}_{i}.csv"
            if not path.exists():
                print(f"[WARN] template file not found: {path}")
                continue

            seq = []
            with path.open("r") as f:
                reader = csv.reader(f)
                header = next(reader, None)  # ヘッダ
                for row in reader:
                    if len(row) < 4:
                        continue
                    try:
                        ax = float(row[1])
                        ay = float(row[2])
                        az = float(row[3])
                        seq.append(np.array([ax, ay, az], dtype=np.float32))
                    except ValueError:
                        continue

            if len(seq) == 0:
                print(f"[WARN] empty template: {path}")
            else:
                print(f"[INFO] loaded template {path} (len={len(seq)})")
                templ_list.append(seq)

        if not templ_list:
            print(f"[ERROR] no templates loaded for label '{label}'")
        templates[label] = templ_list

    return templates


def dtw_distance(seq1, seq2):
    """3次元ベクトル列どうしの DTW 距離"""
    dist, _ = fastdtw(seq1, seq2, dist=lambda x, y: np.linalg.norm(x - y))
    return dist


def classify_gesture(seq, templates):
    """
    DTW で一番近いラベルを選ぶ。
    しきい値より遠かったら None(魔法失敗)を返す。
    """
    if len(seq) < MIN_SAMPLES:
        print(f"[WARN] gesture too short (len={len(seq)}) -> fail")
        return None, float("inf")

    best_label = None
    best_dist = float("inf")

    print("[INFO] --- DTW distances ---")
    for label, templ_list in templates.items():
        if not templ_list:
            continue

        d_list = []
        for templ in templ_list:
            d = dtw_distance(seq, templ)
            d_list.append(d)

        d_min = min(d_list)
        print(f"  {label:8s}: {', '.join(f'{d:.1f}' for d in d_list)} -> min {d_min:.1f}")

        if d_min < best_dist:
            best_dist = d_min
            best_label = label

    if best_dist > DIST_THRESHOLD:
        print(f"[INFO] best_dist={best_dist:.1f} > threshold={DIST_THRESHOLD:.1f} -> fail")
        return None, best_dist

    print(f"[INFO] Recognized as '{best_label}' (dist={best_dist:.1f})")
    return best_label, best_dist


# ================== メイン ==================

def main():
    # Pygame 初期化 & 画面作成
    pygame.init()
    screen = pygame.display.set_mode((800, 600))
    pygame.display.set_caption("Gesture Magic")

    # 最初は暗い画面にしておく
    screen.fill((20, 20, 40))
    pygame.display.flip()

    # テンプレ読み込み
    print("=== Gesture Magic (fancy effects) ===")
    print(f"[INFO] loading templates from {DATA_DIR.resolve()}")
    templates = load_templates()

    # シリアル接続
    print(f"[INFO] opening serial port: {PORT} @ {BAUDRATE}")
    ser = serial.Serial(PORT, BAUDRATE, timeout=1)

    print("\n準備完了。")
    print("M5 のボタン A を押しながらジェスチャーを描くと、")
    print("  triangle -> FIRE, square -> ICE, circle -> THUNDER, star -> LIGHT")
    print("として画面にエフェクトが出ます。Ctrl+C で終了。\n")

    try:
        while True:
            pump_pygame_or_quit()

            # 1 ジェスチャー分受信
            seq = read_one_gesture(ser)

            # DTW で分類
            label, dist = classify_gesture(seq, templates)

            if label is None:
                print(">>> ✖ 魔法失敗")
            else:
                if label == "triangle":
                    effect = "FIRE"
                elif label == "square":
                    effect = "ICE"
                elif label == "circle":
                    effect = "THUNDER"
                elif label == "star":
                    effect = "LIGHT"
                else:
                    effect = label
                print(f">>> ✅ 魔法発動: {label} -> {effect}")

            # 画面にエフェクト表示
            show_effect(screen, label)

    except KeyboardInterrupt:
        print("\n[INFO] interrupted by user")

    finally:
        ser.close()
        pygame.quit()
        print("[INFO] serial closed, pygame quit")


if __name__ == "__main__":
    main()



使い方まとめ

  1. すでにやった:
    • collect_templates.pydata/*.csv 収集済み
    • M5 側は Bluetooth 送信用スケッチが書き込まれている前提
  2. このファイルを magic_gesture.py として保存。
  3. ポート名を自分の環境に合わせて修正:
PORT = "/dev/tty.GestureWand-ESP32SPP"
  1. ライブラリ(まだだったら):
pip install pyserial fastdtw numpy pygame
  1. 実行:
python magic_gesture.py
  1. 流れ:
  • Pygame ウィンドウに「Gesture Magic」待機画面が出る
  • M5を手に持ち、ボタンAを押しながら三角/四角/丸/星を描く
  • ボタンを離す → DTW で認識 → 画面が炎/氷/雷/光 or 魔法失敗に切り替わる
  • 結果と距離の詳細はターミナルに出る

チューニングのポイント(やるなら)

  • ログに出る:
[INFO] --- DTW distances ---
  triangle: 12.3, 17.8, 14.1  -> min 12.3
  square  : 40.5, 38.9, 42.0  -> min 38.9
  circle  : 55.0, 50.1, 49.8  -> min 49.8
  star    : 60.2, 58.7, 62.0  -> min 58.7
[INFO] Recognized as 'triangle' (dist=12.3)
>>> ✅ 魔法発動: triangle -> 🔥 FIRE
  • 「正しく認識されたときの距離の典型値」と
    「明らかに違う動きのときの距離の典型値」の中間を
    DIST_THRESHOLD に置いてやれば、「魔法失敗」判定の分離が良くなります。

完成

※結構誤認識しているのは持ち方の問題かなと これくらいであれば加速度の合成値で十分だったかも