Step1 データ生成(PC/Python):p5.jsで動画からLEDを光らせるための時刻付きCSVデータを書き出す

なぜPythonで処理するのか?

これまでWebブラウザ(p5.js)で行っていた動画解析を、PC上のPythonスクリプトで行う手法(プリレンダリング)に切り替えます。

  • Python (事前生成) のメリット:
    • 動画の全フレームを「理論上の完璧な時間」で解析し、静的なCSVデータとして保存できます。これにより、M5StickC側では再生に専念でき、完全な同期が実現します。
  • p5.js (リアルタイム解析) の課題:
    • ブラウザの負荷や通信状況により、フレーム落ち(処理遅延)が発生し、音楽と光がズレる原因になる。

「理論上完璧な時間」について、一言でいうと、「PCの処理都合を一切無視した、動画の設計図通りの時間」のことです。

動画はパラパラ漫画であり、「1秒間に30枚めくる」といった設計図(FPS)で各コマの時刻が数学的に決まっています。これが理論値です。

  • p5.js(リアルタイム)の弱点: 動画を再生しながら解析するため、「PCの負荷で処理が遅れる」「コマが飛ぶ」といった現実の都合に影響され、時刻がズレます。
  • Python(事前生成)の強み: 現実の時間を止めて、ファイルを1コマずつ取り出して解析します。計算にどれだけ時間がかかろうと、「設計図ではこのコマは0.033秒」と決め打ちで記録するため、絶対にズレないデータが作れます。

この「設計図通りの完璧な時刻表」をM5StickC Plus2が正確に再生するから、完全な同期が実現するのです。

環境構築

では、実行していきましょう。

PC(ローカル)または Google Colab 上で実行できます。とりあえずGoogle Colabが楽でいいかなと思います。 必ずドライブにコピーを実行してから進めてください。

https://colab.research.google.com/drive/10hz70cgfMnnaU-bLt43Sutc3wVhRje3L?usp=sharing

インストール

必要なライブラリをインストールします。

!pip install opencv-python numpy

コード

このコードは、動画 (clip.mp4) を読み込み、Step 1-4のp5.jsと同じロジック(320×180にリサイズして15点サンプリング)で色を抽出し、固定長のCSVを書き出します。

Pythonコード(generate_led_csv.py)
import cv2
import numpy as np

# === 設定 ===
VIDEO_PATH = "clip.mp4"       # 読み込む動画ファイル
OUTPUT_CSV = "led15_log.csv"  # 書き出すCSVファイル
LED_COUNT = 15                # LEDの数
TARGET_WIDTH = 320            # p5.jsと同じ解析解像度(横)
TARGET_HEIGHT = 180           # p5.jsと同じ解析解像度(縦)

def main():
    # 動画を開く
    cap = cv2.VideoCapture(VIDEO_PATH)
    
    if not cap.isOpened():
        print(f"Error: Could not open {VIDEO_PATH}")
        return

    # 動画情報の取得
    fps = cap.get(cv2.CAP_PROP_FPS)
    frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
    duration = frame_count / fps
    
    print(f"Video FPS: {fps}")
    print(f"Total Frames: {frame_count}")
    print(f"Duration: {duration:.2f} sec")
    print("Processing...")

    csv_lines = []
    
    current_frame = 0
    
    while True:
        ret, frame = cap.read()
        if not ret:
            break # 動画の終わり

        # p5.jsのロジックに合わせるためリサイズ
        # (処理速度向上と、サンプリング位置の互換性のため)
        resized = cv2.resize(frame, (TARGET_WIDTH, TARGET_HEIGHT))
        
        # 色空間は OpenCV(BGR) -> RGB に変換が必要
        rgb_frame = cv2.cvtColor(resized, cv2.COLOR_BGR2RGB)
        
        # LEDデータの抽出
        led_colors = []
        sample_y = TARGET_HEIGHT // 2 # 画面中央のY座標
        
        for i in range(LED_COUNT):
            # X座標の計算 (p5.jsの map() と同じロジック)
            # 0 〜 LED_COUNT-1 を 0 〜 WIDTH-1 にマッピング
            x = int(np.interp(i, [0, LED_COUNT - 1], [0, TARGET_WIDTH - 1]))
            
            # ピクセル取得
            pixel = rgb_frame[sample_y, x]
            led_colors.append(pixel) # [R, G, B]

        # --- CSV行の作成 ---
        
        # 1. 時刻 (ミリ秒)
        # フレーム番号 / FPS * 1000
        time_ms = int((current_frame / fps) * 1000)
        time_str = f"{time_ms:09d}" # 9桁ゼロ埋め
        
        # 2. RGB値
        color_strs = []
        for r, g, b in led_colors:
            color_strs.append(f"{r:03d},{g:03d},{b:03d}") # 3桁ゼロ埋め
            
        # 結合
        line = f"{time_str},{','.join(color_strs)}"
        csv_lines.append(line)
        
        current_frame += 1
        
        # 進捗表示 (100フレームごと)
        if current_frame % 100 == 0:
            print(f"Processed {current_frame}/{frame_count} frames...")

    # ファイル保存
    with open(OUTPUT_CSV, "w") as f:
        # Windows環境などを考慮して改行コードを明示しても良いが、
        # Pythonのデフォルトで書き込む
        f.write("\n".join(csv_lines))
        
    cap.release()
    print(f"\nDone! Saved to {OUTPUT_CSV}")
    print(f"Frame duration for M5: {1000/fps:.4f} ms") # M5のコード設定用

if __name__ == "__main__":
    main()

コードの解説

以下のスクリプト generate_led_csv.py の主要な処理ブロックを解説します。

メタデータの取得とループ処理

cap = cv2.VideoCapture(VIDEO_PATH)
fps = cap.get(cv2.CAP_PROP_FPS) # フレームレート(1秒間のコマ数)を取得
# ...
while True:
    ret, frame = cap.read() # 1フレーム読み込む
    if not ret:
        break # 読み込めなくなったら終了(動画の末尾)

cap.read() は動画の先頭から1コマずつ画像を読み込みます。これを While ループで回すことで、動画の全ての瞬間を逃さず処理します。

画像の前処理

# リサイズ(処理の軽量化)
resized = cv2.resize(frame, (TARGET_WIDTH, TARGET_HEIGHT))

# 色空間の変換 (BGR -> RGB)
rgb_frame = cv2.cvtColor(resized, cv2.COLOR_BGR2RGB)

OpenCVの罠: 一般的な画像データは RGB (赤・緑・青) の順で並んでいますが、OpenCVは歴史的な経緯により BGR (青・緑・赤) の順で読み込みます。これを変換せずに使うと、LEDの色が変(赤と青が逆)になるため、必ず cv2.cvtColor で変換します。

LEDに対応する画素のサンプリング

※ led colorsの配列内の値は3つ(R, G, B)あります。


led_colors = []
sample_y = TARGET_HEIGHT // 2 # 画面中央の高さを取得

for i in range(LED_COUNT):
    # LEDのインデックス(0~14)を画像の横幅(0~319)にマッピング
    x = int(np.interp(i, [0, LED_COUNT - 1], [0, TARGET_WIDTH - 1]))
    
    pixel = rgb_frame[sample_y, x] # その座標の色を取得
    led_colors.append(pixel)

np.interp (線形補間) を使い、15個のLEDが画面の左端から右端まで均等に配置されたと仮定した場合の X座標 を計算しています。

タイムスタンプ計算とCSV整形

# 時刻 (ミリ秒) = 現在のフレーム番号 / FPS * 1000
time_ms = int((current_frame / fps) * 1000)
time_str = f"{time_ms:09d}" # 9桁でゼロ埋め (例: 000001500)

# RGB値の整形
color_strs = []
for r, g, b in led_colors:
    color_strs.append(f"{r:03d},{g:03d},{b:03d}") # 3桁ゼロ埋め

# CSVの1行を作成
line = f"{time_str},{','.join(color_strs)}"

フォーマットの工夫

  • ゼロ埋め (:09d, :03d):
    • データの文字数を固定長(常に同じ長さ)にしています。
    • これにより、M5StickCのようなマイコン側でデータを受信する際、文字読み取りの処理が非常に簡単かつ高速になります。

生成されるデータのイメージ

出力された led15_log.csv は以下のようになります。

時刻(ms)LED1(R,G,B)LED2(R,G,B)LED15(R,G,B)
000000000010,010,010012,012,012005,005,005
000000033050,020,010055,022,012010,010,010
000000066100,050,050110,055,055020,020,020
  • 1列目: 動画開始からの経過時間(ミリ秒)。
  • それ以降: 各LEDのRGB値。

M5StickCへの実装手順

Pythonスクリプトを実行すると、最後に以下のようなログが出力されます。

Done! Saved to led15_log.csv
Frame duration for M5: 33.3333 ms

ffmpegで動画を 30fps (フレーム/秒) に設定したため、この 33.3333 ms という値は 1000ミリ秒 / 30フレーム = 33.3333... ミリ秒 として正確に計算された「1コマあたりの時間」です。

この値は、M5StickCが次のLEDデータを読み込むまでの待機時間として使用されます。

それでは、作成したCSVデータ(led15_log.csv)をダウンロードして、sdカードに保存しておきましょう。

※ 細かいところ気になりますが、ここでSDカードを保存すること忘れずに。