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) |
000000000 | 010,010,010 | 012,012,012 | … | 005,005,005 |
000000033 | 050,020,010 | 055,022,012 | … | 010,010,010 |
000000066 | 100,050,050 | 110,055,055 | … | 020,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カードに保存しておきましょう。
