シングルLEDライトペインティング:自作モーター駆動システムで動的な画像を描く
部品と材料
| NEMA17ステッピングモーター ×2 |
| Theremino DRV8825ステッピングモータードライバー ×2 |
| SDカードリーダー ×1 |
| Arduino Mega 2560 ×1 |
| タイミングベルトとVホイール各種 ×1式 |
必要な工具と機械
- 3Dプリンター(汎用)
アプリとオンラインサービス
- Arduino IDE
- GIMP(画像編集)
- Autodesk Fusion 360(CAD設計)
プロジェクトについて
アイデア
ライトペインティング(長時間露光で光の軌跡を撮影する手法)の動画や記事を見て、自分でも挑戦したくなりました。通常は小さな光源を動かして線を描きますが、もっと詳細な絵や多色表現をしたいと思い、単一のRGB LEDを搭載した2軸CNCマシンを自作して「光で絵を描く」システムを構築しました。
計画
このプロジェクトには4つの主要コンポーネントが必要です:
- 2軸CNCマシン
- RGB LED
- SDカード
- 長時間露光撮影可能なカメラ
Arduino MegaがSDカードからビットマップ画像を読み取り、水平方向に移動しながら対応するピクセルの色でLEDを点灯させ、1行終わるごとに垂直方向に1ステップ下がります。全ピクセル描画後、次のビットマップを探索し、画像がなくなるまで繰り返します。
リグの構築
CNCマシンの設計・製作経験を活かし、モジュラーで拡張性のあるシンプルな設計にしました。平行なアルミ押出材にクロスバーを取り付け、2本のタイミングベルトでX/Y軸を駆動します。これにより各軸の長さを自由にカスタマイズ可能です。X軸の両端には3Dプリントしたエンドキャップを取り付け、片側にはX軸ステッピングモーターとベアリングのマウントを設けています。
ビットマップの読み取り
シンプルで読み取りやすいBMP形式を採用しました。ファイルヘッダー内の重要なアドレス(0x12:幅、0x16:高さ、0x1C:色深度、0xA:ピクセルデータ位置、0x36:通常のピクセルデータ開始位置)を読み取り、16bitまたは32bit単位でデータを取得しながらピクセルを行ごとに読み込みます。
画像の準備
一般的なカメラの最大露光時間は30秒程度のため、描画可能なピクセル数は約288個(≒18×16ピクセル)に制限されます。GIMPでシンプルなピクセルアート(ポケボール、ハート、ジャンプするマリオ)を作成し、SDカードの「bitmaps」フォルダに配置します。プログラムはこのフォルダ内の全画像を順に読み込みます。
描画プログラム
ステッピングモーターには位置フィードバックがないため、ソフトウェアでグリッド座標系を用いてLED位置を管理します。起動時に原点(0,0)に設定し、最初の画像を読み込みます。LEDを5回点滅させて撮影開始の合図を送り、ビットマップを行・列の順で走査しながらステッピングモーターを対応位置に移動、各位置でピクセルの色に合わせてLEDを点灯させます。
画像の再現
SDカードを挿入し、モーター用12V電源を投入して起動。カメラ設定:露光20秒、絞りF36、ISO100、露出補正-5段(ゴースト軽減)。
最初のポケボール:
ハート(9×9ピクセル):
ジャンプするマリオ:
今後の改善案
- 移動中はLEDを消灯し、停止時にのみ点灯させることでブレを大幅に低減し、画像の鮮明度を向上させる
コード
Light Painting Program (C/C++)
// Bitmap reading function partially from Adafruit
#include <SD.h>
#include <SPI.h>
#include "DRV8825.h"
#define MOTOR_STEPS 200
#define RPM 150
#define MICROSTEPS 4
// pin definitions
#define STEPPER_X_DIR 7
#define STEPPER_X_STEP 6
#define STEPPER_X_EN 8
#define STEPPER_Y_DIR 4
#define STEPPER_Y_STEP 5
#define STEPPER_Y_EN 12
#define X 0
#define Y 1
#define X_DIR_FLAG -1 // 1 or -1 to flip direction
#define Y_DIR_FLAG 1 // 1 or -1 to flip direction
#define STEPS_PER_MM (3.75 * MICROSTEPS)
#define SPACE_BETWEEN_POSITIONS 5 // 5mm per move
#define R A0
#define G A1
#define B A2
#define SD_CS 22
int currentPositions[] = {0, 0};
DRV8825 stepperX(MOTOR_STEPS, STEPPER_X_DIR, STEPPER_X_STEP, STEPPER_X_EN);
DRV8825 stepperY(MOTOR_STEPS, STEPPER_Y_DIR, STEPPER_Y_STEP, STEPPER_Y_EN);
void setup() {
Serial.begin(115200);
init_steppers();
SD.begin(SD_CS);
createBitmaps();
stepperX.disable();
stepperY.disable();
while(1);
}
void loop() {}
void createBitmaps(){
File dir = SD.open("bitmaps");
while(true){
File bitmap = dir.openNextFile();
if(!bitmap){ break; }
paintBitmap(bitmap);
delay(15000);
}
}
#define BUFFPIXEL 20
void paintBitmap(File bmpFile){
int bmpWidth, bmpHeight;
uint8_t bmpDepth;
uint32_t bmpImageOffset;
uint32_t rowSize;
uint8_t sdbuffer[3 * BUFFPIXEL];
uint8_t buffidx = sizeof(sdbuffer);
boolean goodBmp = false;
boolean flip = true;
int w, h, row, col;
uint8_t r, g, b;
uint32_t pos = 0, startTime = millis();
Serial.println();
Serial.print("Loading image '");
Serial.print(bmpFile.name());
Serial.println("'");
if (read16(bmpFile) == 0x4D42) { // BMP signature
Serial.print("File size: "); Serial.println(read32(bmpFile));
(void)read32(bmpFile); // creator bytes
bmpImageOffset = read32(bmpFile);
Serial.print("Image Offset: "); Serial.println(bmpImageOffset, DEC);
Serial.print("Header size: "); Serial.println(read32(bmpFile));
bmpWidth = read32(bmpFile);
bmpHeight = read32(bmpFile);
if (read16(bmpFile) == 1) { // planes
bmpDepth = read16(bmpFile);
Serial.print("Bit Depth: "); Serial.println(bmpDepth);
if ((bmpDepth == 24) && (read32(bmpFile) == 0)) { // uncompressed
goodBmp = true;
Serial.print("Image size: "); Serial.print(bmpWidth); Serial.print('x'); Serial.println(bmpHeight);
rowSize = (bmpWidth * 3 + 3) & ~3;
if (bmpHeight < 0) { bmpHeight = -bmpHeight; flip = false; }
w = bmpWidth; h = bmpHeight;
if(bmpWidth*bmpHeight > 290){
Serial.println("File is too large to be printed."); return;
}
for(uint8_t i=0; i<5; i++){
analogWrite(R, 150); delay(500);
analogWrite(R, 0); delay(500);
}
for (row = 0; row < h; row++) {
moveToPosition(0, row);
if (flip) pos = bmpImageOffset + (bmpHeight - 1 - row) * rowSize;
else pos = bmpImageOffset + row * rowSize;
if (bmpFile.position() != pos) {
bmpFile.seek(pos);
buffidx = sizeof(sdbuffer);
}
for (col = 0; col < w; col++) {
if (buffidx >= sizeof(sdbuffer)) {
bmpFile.read(sdbuffer, sizeof(sdbuffer));
buffidx = 0;
}
b = sdbuffer[buffidx++];
g = sdbuffer[buffidx++];
r = sdbuffer[buffidx++];
moveToPosition(col, row);
activateLED(r,g,b);
}
analogWrite(R, 0); analogWrite(G, 0); analogWrite(B, 0);
}
Serial.print("Loaded in "); Serial.print(millis() - startTime); Serial.println(" ms");
}
}
}
bmpFile.close();
moveToPosition(0,0);
if (!goodBmp) Serial.println("BMP format not recognized.");
}
uint16_t read16(File f) {
uint16_t result;
((uint8_t *)&result)[0] = f.read(); // LSB
((uint8_t *)&result)[1] = f.read(); // MSB
return result;
}
uint32_t read32(File f) {
uint32_t result;
((uint8_t *)&result)[0] = f.read();
((uint8_t *)&result)[1] = f.read();
((uint8_t *)&result)[2] = f.read();
((uint8_t *)&result)[3] = f.read(); // MSB
return result;
}
void activateLED(int r, int g, int b){
Serial.print(F("LED has value of: "));
Serial.print(r); Serial.print(", "); Serial.print(g); Serial.print(", "); Serial.println(b);
analogWrite(R, r); analogWrite(G, g); analogWrite(B, b);
}
void moveToPosition(int x, int y){
int newPosX = (x-currentPositions[X])*STEPS_PER_MM*X_DIR_FLAG*SPACE_BETWEEN_POSITIONS;
int newPosY = (y-currentPositions[Y])*STEPS_PER_MM*Y_DIR_FLAG*SPACE_BETWEEN_POSITIONS;
stepperX.move(newPosX);
stepperY.move(newPosY);
currentPositions[X] = x;
currentPositions[Y] = y;
Serial.print("Stepper positions: ");
Serial.print(currentPositions[X]); Serial.print(", "); Serial.println(currentPositions[Y]);
}
void init_steppers(){
stepperX.begin(RPM);
stepperX.setEnableActiveState(LOW);
stepperX.enable();
stepperX.setMicrostep(MICROSTEPS);
stepperY.begin(RPM);
stepperY.setEnableActiveState(LOW);
stepperY.enable();
stepperY.setMicrostep(MICROSTEPS);
}
CADファイル
カスタムパーツとエンクロージャーの設計ファイル
回路図

製造プロセス
- FMEAマスターガイド:メンテナンスマネージャーのための故障モード影響解析構築・活用ステップバイステップ
- プロダクトデザインで自然素材への回帰が進む理由
- Raspberry PiでPIRモーションセンサーとLEDを制御
- Firebase経由で2台のArduino間を温湿度データで連携するIoTチュートリアル
- Arduinoで「棺桶ダンス」メロディーを再現するプロジェクト
- 古いリモコンを再利用:ArduinoでDIYスマートデバイスを構築
- FirmataとXbox OneコントローラーでArduinoローバーをシームレス操作
- 音声反応型 8 LED ライトディスプレイ
- スマートArduino四足歩行ロボットの製作:ステップバイステップガイド
- LEDによる無線データ転送(Li-Fi)— Arduinoプロジェクト
- Pythonで一時変数なしで2変数をスワップするワンライナー