限界突破: Arduino NanoでSSD1106 OLEDに30FPS動画表示 – SPIオーバークロック&ディザリング
必要な部品と材料
| Arduino Nano R3 (ATmega328P, 16MHz) × 1 |
| 0.96インチ OLEDディスプレイ (SSD1106, 128×64, SPI接続) × 1 |
使用ソフトウェア・ライブラリ
プロジェクト概要
SSD1106 OLED (128×64ピクセル、白黒2値) は通常、テキストや簡易アニメーション表示用途ですが、SPIバスを 2MHz (標準は 4MHz 以下だが、AVRのSPIクロック分周設定で最適化) にオーバークロックし、PC側で OpenCV を用いて動画をフレーム分解→リサイズ→フロイド・スタインバーグ ディザリング (誤差拡散法) で疑似グレースケール化→シリアル (UART 1Mbps) で高速転送することで、**約 30 FPS の動画再生** を実現したプロジェクトです。
技術的チャレンジと解決策
| 課題 | 解決策 |
| SPI帯域不足 (128×64=8192ピクセル=1024バイト/フレーム、30FPS=30KB/s、SPI 4MHz=500KB/s 理論上余裕だがオーバーヘッド大) |
SPIクロック最適化、コマンド/データ切替最小化、フレームバッファー一括転送 |
| UART帯域 (PC→Arduino) がボトルネック |
UART 1Mbps (115200の約8.7倍) 設定、バイナリ転送、フレーム差分圧縮検討 |
| 白黒2値表示しかできない |
フロイド・スタインバーグ ディザリングで疑似階調表現 |
| Arduino RAM不足 (2KBしかない) |
フレームバッファー持たず、受信即表示 (ストリーミング方式) |
| 電源ノイズで通信エラー |
デカップリングコンデンサ、短い配線、安定化電源 |
システム構成
[PC: Python + OpenCV]
→ 動画ファイル/カメラ読込
→ リサイズ (128x64)
→ グレースケール変換
→ フロイド・スタインバーグ ディザリング (1bit/pixel)
→ バイナリフレームデータ (1024バイト) 生成
→ UART (1Mbps) 送信
↓ USB-CDC / FTDI
[Arduino Nano (ATmega328P)]
→ UART受信 (割り込み/ポーリング)
→ SPI (2-4MHz) でSSD1106へ一括転送
→ 表示
配線 (Arduino Nano → SSD1106 SPI)
| OLEDピン | 機能 | Arduino Nanoピン |
| VCC | 3.3V/5V (モジュール仕様確認) | 3.3V or 5V |
| GND | GND | GND |
| D0 (SCLK) | SPI Clock | D13 (SCK) |
| D1 (MOSI/SDA) | SPI Data | D11 (MOSI) |
| RES (RST) | Reset | D8 (任意GPIO) |
| DC (A0) | Data/Command | D9 (任意GPIO) |
| CS | Chip Select | D10 (SS) または GND (常時選択) |
PC側 Python コード概要 (OpenCV + pyserial)
import cv2
import serial
import numpy as np
import time
ser = serial.Serial('COM3', 1000000, timeout=0) # 1Mbps
cap = cv2.VideoCapture('video.mp4') # or 0 for webcam
def floyd_steinberg_dither(img):
# img: uint8 0-255, shape (64, 128)
h, w = img.shape
out = np.zeros((h, w), np.uint8)
# 作業用コピー (floatで誤差蓄積)
work = img.astype(np.float32)
for y in range(h):
for x in range(w):
old = work[y, x]
new = 255 if old > 127 else 0
out[y, x] = new
err = old - new
# 誤差拡散
if x + 1 < w: work[y, x+1] += err * 7/16
if y + 1 < h:
if x > 0: work[y+1, x-1] += err * 3/16
work[y+1, x] += err * 5/16
if x+1 < w: work[y+1, x+1] += err * 1/16
return out
def frame_to_bytes(dithered):
# 1bit/pixel → 8ピクセル=1バイト (MSB first, page addressing)
# SSD1106はページアドレッシング (8行=1ページ × 8ページ)
bytes_out = bytearray(1024)
idx = 0
for page in range(8):
for col in range(128):
b = 0
for bit in range(8):
y = page * 8 + bit
if dithered[y, col] > 127:
b |= (1 << (7 - bit)) # MSB first
bytes_out[idx] = b
idx += 1
return bytes_out
while True:
ret, frame = cap.read()
if not ret: break
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
small = cv2.resize(gray, (128, 64), interpolation=cv2.INTER_AREA)
dithered = floyd_steinberg_dither(small)
data = frame_to_bytes(dithered)
ser.write(data)
# 30FPS = 33.3ms/frame だが、送信時間考慮して調整
time.sleep(0.033)
ser.close()
cap.release()
Arduino側スケッチ概要
#include <SPI.h>
#define OLED_DC 9
#define OLED_RST 8
#define OLED_CS 10
const int WIDTH = 128;
const int HEIGHT = 64;
const int PAGES = 8;
const int FRAME_SIZE = 1024; // 128 * 64 / 8
uint8_t frameBuf[FRAME_SIZE];
volatile uint16_t bufIdx = 0;
volatile bool frameReady = false;
void setup() {
pinMode(OLED_DC, OUTPUT);
pinMode(OLED_RST, OUTPUT);
pinMode(OLED_CS, OUTPUT);
digitalWrite(OLED_CS, HIGH);
// OLEDリセット
digitalWrite(OLED_RST, LOW); delay(10); digitalWrite(OLED_RST, HIGH);
// SPI高速化: クロック分周2 (8MHz/2=4MHz) または4 (2MHz)
SPI.begin();
SPI.setClockDivider(SPI_CLOCK_DIV2); // 4MHz
SPI.setBitOrder(MSBFIRST);
SPI.setDataMode(SPI_MODE0);
// SSD1106初期化コマンドシーケンス...
oledInit();
Serial.begin(1000000); // 1Mbps UART
}
void loop() {
// UART受信 (割り込み駆動推奨)
while (Serial.available()) {
frameBuf[bufIdx++] = Serial.read();
if (bufIdx >= FRAME_SIZE) {
bufIdx = 0;
frameReady = true;
}
}
if (frameReady) {
frameReady = false;
sendFrameToOled(frameBuf);
}
}
void sendFrameToOled(uint8_t *buf) {
// ページアドレッシングモードで全ページ書き込み
for (uint8_t page = 0; page < PAGES; page++) {
oledCommand(0xB0 | page); // ページアドレス設定
oledCommand(0x00); // 下位カラムアドレス
oledCommand(0x10); // 上位カラムアドレス
digitalWrite(OLED_DC, HIGH); // データモード
digitalWrite(OLED_CS, LOW);
// 1ページ = 128バイト 一括SPI転送
for (uint8_t col = 0; col < WIDTH; col++) {
SPI.transfer(buf[page * WIDTH + col]);
}
digitalWrite(OLED_CS, HIGH);
}
}
void oledCommand(uint8_t cmd) {
digitalWrite(OLED_DC, LOW);
digitalWrite(OLED_CS, LOW);
SPI.transfer(cmd);
digitalWrite(OLED_CS, HIGH);
}
void oledInit() {
// SSD1106標準初期化シーケンス (データシート参照)
// 省略: ディスプレイOFF, クロック分周, マルチプレックス比, オフセット,
// スタートライン, チャージポンプ, メモリモード, セグメントリマップ,
// COMスキャン方向, コムピン設定, コントラスト, プリチャージ, VCOMH,
// 全点灯OFF, 正常表示, ディスプレイON
}
ディザリング効果の比較
- ディザリングなし (単純閾値): 階調情報が失われ、輪郭のみの「切り絵」のような画像
- フロイド・スタインバーグ: 誤差を周囲ピクセルに拡散し、擬似的に滑らかな階調を表現。動画では「ザラつき」が動的ノイズとして知覚され、意外と自然に見える
- ベイヤー/順序ディザリング: 固定パターンで高速だが、動画ではパターンが固定ノイズとして目立つ
パフォーマンス実測値 (目安)
| 項目 | 値 |
| フレームレート | 約 25-30 FPS (動画複雑度・PC性能依存) |
| UARTボーレート | 1,000,000 bps (1 Mbps) |
| SPIクロック | 4 MHz (SPI_CLOCK_DIV2 @ 16MHz AVR) |
| フレームデータサイズ | 1024 バイト (128×64 / 8) |
| 理論転送時間 (UART) | 約 8.2 ms (1024×8bit / 1Mbps + オーバーヘッド) |
| 理論転送時間 (SPI) | 約 2.0 ms (1024×8bit / 4MHz) |
| Arduino CPU使用率 | 約 30-40% (受信+表示のみ) |
拡張アイデア
- ESP32/ESP8266 なら Wi-Fi 経由でより高帯域、デュアルコアで並列処理可能
- カラーOLED (SSD1331 等) なら 65K色/26万色表示可能 (帯域要増大)
- 動画ファイル再生だけでなく、リアルタイムカメラ映像ストリーミング (監視カメラ等)
- 音声同期再生 (PC側で再生、Arduino側は映像のみ)
- 圧縮: フレーム差分 (XOR) + RLE で帯域削減