单 LED 光绘:自制电动双轴系统绘制动态图像
元件与材料
| NEMA17 步进电机 | ×2 |
| DRV8825 步进电机驱动板 | ×2 |
| SD 卡读取模块 | ×1 |
| Arduino Mega 2560 | ×1 |
| 同步带、V 型轮等机械传动件 | ×1 套 |
所需工具
3D 打印机(打印结构件)
软件与在线服务
- Arduino IDE
- GIMP(像素图制作)
- Autodesk Fusion 360(结构建模)
项目简介
创意来源
光绘摄影利用长曝光记录移动光源轨迹。若想用单一光源绘制多色、高细节图像该如何?答案是:构建一台搭载 RGB LED 的二轴 CNC 机械臂,让 LED 在空间中按像素点移动并变色,配合相机长曝光,即可“画”出图像。
实施计划
四大核心模块:二轴 CNC 机构、RGB LED、SD 卡存储、长曝光相机。Arduino Mega 从 SD 卡读取位图,控制 X/Y 轴步进电机移动 LED 至各像素坐标并点亮对应颜色,逐行扫描完成一幅图,随后自动寻找下一张图片。
机械结构制作
采用模块化设计:两根同步带驱动横梁在平行铝型材上滑动,行程可按需扩展。X 轴末端 3D 打印端盖,一侧安装步进电机与轴承。
位图读取原理
选用 BMP 格式因其结构简单。关键文件偏移地址:0x12(宽)、0x16(高)、0x1C(色深)、0xA(像素数据起始)、0x36(典型像素数据位置)。程序按 16/32 位分块读取,解析头部信息后逐行逐像素获取 RGB 值。
图像预处理
受限于相机最长 30 秒曝光,单次绘制像素上限约 288 个(约 18×16 像素)。使用 GIMP 绘制极简像素画(宝可梦球、爱心、跳跃马里奥),存入 SD 卡根目录下的 “bitmaps” 文件夹。
绘制程序逻辑
软件维护虚拟网格坐标追踪 LED 位置。启动后归零,LED 闪烁 5 次提示摄影师准备。逐行逐列读取像素,驱动步进电机移动至对应网格坐标,设置 LED 颜色。像素间距 5 mm,微步细分 4,每毫米 3.75 步。
实拍效果
相机设定:20 秒曝光、F36 光圈、ISO 100、-5EV 曝光补偿(抑制鬼影)。
宝可梦球轮廓清晰;爱心因分辨率低像素感强;马里奥因亮色像素多出现明显鬼影。
未来改进方向
主要优化:让 LED 在移动过程中熄灭,仅在定位静止时点亮,大幅减少运动模糊,提升成像锐度。
完整代码
// 位图读取部分参考 Adafruit
#include <SD.h>
#include <SPI.h>
#include "DRV8825.h"
#define MOTOR_STEPS 200
#define RPM 150
#define MICROSTEPS 4
// 引脚定义
#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
#define Y_DIR_FLAG 1
#define STEPS_PER_MM (3.75 * MICROSTEPS)
#define SPACE_BETWEEN_POSITIONS 5
#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) {
Serial.print("File size: "); Serial.println(read32(bmpFile));
(void)read32(bmpFile);
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) {
bmpDepth = read16(bmpFile);
Serial.print("Bit Depth: "); Serial.println(bmpDepth);
if ((bmpDepth == 24) && (read32(bmpFile) == 0)) {
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(); ((uint8_t *)&result)[1] = f.read(); 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(); 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);
}
原理图
制造工艺
- 掌握FMEA:维护经理构建与使用失效模式与影响分析的分步指南
- 产品设计回归天然材料:美学、可持续与制造挑战的深度对话
- 树莓派 PIR 运动传感器控制 LED 实战指南
- 两块 Arduino 通过 Firebase 传递温湿度数据
- Arduino复刻“棺材舞”旋律:蜂鸣器+三色LED灯光秀完整教程
- 焕新旧遥控器:用 Arduino 打造 DIY 智能红外控制设备
- Firmata + Xbox 手柄无线遥控 Arduino 小车:UWP 应用桥接方案
- 声控 8 路 LED 灯光秀:Arduino 入门项目
- 从零构建智能 Arduino 四足机器人:完整指南
- 可见光通信 (Li-Fi) 实验:Arduino 实现 LED 无线数据传输
- Bolt IoT + Arduino UNO 实现 6 路 LED 无级调光
- Python 优雅交换两变量:无需临时变量的一行代码