Skip to content

Instantly share code, notes, and snippets.

@Yuikawa-Akira
Last active November 1, 2025 04:56
Show Gist options
  • Select an option

  • Save Yuikawa-Akira/7e2eaef2d79a73e506a7a853aa58a739 to your computer and use it in GitHub Desktop.

Select an option

Save Yuikawa-Akira/7e2eaef2d79a73e506a7a853aa58a739 to your computer and use it in GitHub Desktop.
AtomS3R Pixel-art style camera
#include <esp_camera.h>
#include <FastLED.h>
#include <SPI.h>
#include <SD.h>
#include <M5Unified.h>
#define KEY_PIN 1
#define LED_PIN 2
#define POWER_GPIO_NUM 18
CRGB LED[1];
camera_fb_t* fb;
M5Canvas canvas0;
// SDカード保存用
char filename[64];
int filecounter = 1;
uint8_t graydata[240 * 176]; // HQVGAが今のところ最適
// 最大8色のカラーパレット
uint32_t ColorPalettes[8][8] = {
{ // パレット0 slso8
0x0D2B45, 0x203C56, 0x544E68, 0x8D697A, 0xD08159, 0xFFAA5E, 0xFFD4A3, 0xFFECD6 },
{ // パレット1 都市伝説解体センター風
0x000000, 0x000B22, 0x112B43, 0x437290, 0x437290, 0xE0D8D1, 0xE0D8D1, 0xFFFFFF },
{ // パレット2 ファミレスを享受せよ風
0x010101, 0x33669F, 0x33669F, 0x33669F, 0x498DB7, 0x498DB7, 0xFBE379, 0xFBE379 },
{ // パレット3 gothic-bit
0x0E0E12, 0x1A1A24, 0x333346, 0x535373, 0x8080A4, 0xA6A6BF, 0xC1C1D2, 0xE6E6EC },
{ // パレット4 noire-truth
0x1E1C32, 0x1E1C32, 0x1E1C32, 0x1E1C32, 0xC6BAAC, 0xC6BAAC, 0xC6BAAC, 0xC6BAAC },
{ // パレット5 2BIT DEMIBOY
0x252525, 0x252525, 0x4B564D, 0x4B564D, 0x9AA57C, 0x9AA57C, 0xE0E9C4, 0xE0E9C4 },
{ // パレット6 deep-maze
0x001D2A, 0x085562, 0x009A98, 0x00BE91, 0x38D88E, 0x9AF089, 0xF2FF66, 0xF2FF66 },
{ // パレット7 night-rain
0x000000, 0x012036, 0x3A7BAA, 0x7D8FAE, 0xA1B4C1, 0xF0B9B9, 0xFFD159, 0xFFFFFF },
};
int currentPalettelndex = 0; // 現在のパレットのインデックス
int maxPalettelndex = 8; // パレット総数
// TailBATを使用しているとき、消費電流が45mA以下だと電源がシャットダウンしてしまう対策
uint32_t LED_ON_DURATION = 120000; // LED 点灯時間 (ミリ秒)
uint32_t keyOnTime = 0; // キースイッチを操作した時間
int dither = 0; // 0 = ディザ未使用 1 = ディザ使用
int levels = 8; // ディザ階調数 2以上
camera_config_t camera_config = {
.pin_pwdn = -1,
.pin_reset = -1,
.pin_xclk = 21,
.pin_sscb_sda = 12,
.pin_sscb_scl = 9,
.pin_d7 = 13,
.pin_d6 = 11,
.pin_d5 = 17,
.pin_d4 = 4,
.pin_d3 = 48,
.pin_d2 = 46,
.pin_d1 = 42,
.pin_d0 = 3,
.pin_vsync = 10,
.pin_href = 14,
.pin_pclk = 40,
.xclk_freq_hz = 20000000,
.ledc_timer = LEDC_TIMER_0,
.ledc_channel = LEDC_CHANNEL_0,
.pixel_format = PIXFORMAT_RGB565,
.frame_size = FRAMESIZE_HQVGA,
// FRAMESIZE_96X96, // 96x96
// FRAMESIZE_QQVGA, // 160x120
// FRAMESIZE_QCIF, // 176x144
// FRAMESIZE_HQVGA, // 240x176
// FRAMESIZE_240X240, // 240x240
// FRAMESIZE_QVGA, // 320x240
.jpeg_quality = 0,
.fb_count = 2,
.fb_location = CAMERA_FB_IN_PSRAM,
.grab_mode = CAMERA_GRAB_LATEST,
.sccb_i2c_port = 0,
};
bool loadPaletteFromSD(int paletteIndex) {
if (paletteIndex < 0 || paletteIndex > 7) {
return false;
}
// ファイル名を生成 (例: /ColorPalette0.txt)
String filename = "/ColorPalette" + String(paletteIndex) + ".txt";
// ファイルが存在するか確認
if (!SD.exists(filename)) {
return false; // ファイルが存在しない場合はデフォルトを使うのでfalseを返す
}
// ファイルを開く
File file = SD.open(filename, FILE_READ);
if (!file) {
return false; // ファイルオープン失敗
}
// ファイルから8つのカラーコードを読み込む
int colorCount = 0;
while (file.available() && colorCount < 8) {
String line = file.readStringUntil('\n'); // 1行読み込む
line.trim(); // 前後の空白や改行文字を削除
if (line.length() > 0) {
// strtoul(const char *str, char **endptr, int base)
// base=0 で 0x (16進), 0 (8進), それ以外 (10進) を自動判別
uint32_t colorValue = strtoul(line.c_str(), NULL, 0);
// エラーチェック (strtoulはエラー時に0を返すことがあるが、0x000000も有効な色なので完全ではない)
// ここでは単純に読み込んだ値を格納する
ColorPalettes[paletteIndex][colorCount] = colorValue;
colorCount++;
}
}
file.close(); // ファイルを閉じる
// 8色読み込めたか確認
if (colorCount == 8) {
return true; // 成功
} else {
// もし8色以下の場合は読み込めた分だけ反映して残りはデフォルトを使用する
return false; // 読み込み失敗(色が足りない)
}
}
bool CameraBegin() {
esp_err_t err = esp_camera_init(&camera_config);
if (err != ESP_OK) {
return false;
}
//カメラ追加設定
sensor_t* s = esp_camera_sensor_get();
s->set_vflip(s, 0); //上下反転 0無効 1有効
s->set_hmirror(s, 0); //左右反転 0無効 1有効
// s->set_colorbar(s, 1); //カラーバー 0無効 1有効
// s->set_brightness(s, 1); // up the brightness just a bit
// s->set_saturation(s, 0); // lower the saturation
return true;
}
bool CameraGet() {
fb = esp_camera_fb_get();
if (!fb) {
return false;
}
return true;
}
bool CameraFree() {
if (fb) {
esp_camera_fb_return(fb);
return true;
}
return false;
}
void saveToSD_OriginalBMP() {
sprintf(filename, "/%010d_%04d_Original.bmp", keyOnTime, filecounter);
File file = SD.open(filename, "w");
if (file) {
uint8_t* out_bmp = NULL;
size_t out_bmp_len = 0;
frame2bmp(fb, &out_bmp, &out_bmp_len);
file.write(out_bmp, out_bmp_len);
file.close();
free(out_bmp);
} else {
LED[0] = CRGB::Red;
FastLED.show(); //Error!
}
}
void saveToSD_ConvertBMP() {
sprintf(filename, "/%010d_%04d_palette%01d.bmp", keyOnTime, filecounter, currentPalettelndex);
File file = SD.open(filename, "w");
if (file) {
int width = fb->width;
int height = fb->height;
int rowSize = (3 * width + 3) & ~3;
lgfx::bitmap_header_t bmpheader;
bmpheader.bfType = 0x4D42;
bmpheader.bfSize = rowSize * height + sizeof(bmpheader);
bmpheader.bfOffBits = sizeof(bmpheader);
bmpheader.biSize = 40;
bmpheader.biWidth = width;
bmpheader.biHeight = height;
bmpheader.biPlanes = 1;
bmpheader.biBitCount = 24;
bmpheader.biCompression = 0;
bmpheader.biSizeImage = 0; //以下、MacOS向けに追加
bmpheader.biXPelsPerMeter = 2835;
bmpheader.biYPelsPerMeter = 2835;
bmpheader.biClrUsed = 0;
bmpheader.biClrImportant = 0;
file.write((std::uint8_t*)&bmpheader, sizeof(bmpheader));
std::uint8_t buffer[rowSize];
memset(&buffer[rowSize - 4], 0, 4);
for (int y = height - 1; y >= 0; y--) {
for (int x = 0; x < width; x++) {
//グレイデータを読み出す
int i_gray = y * width + x;
uint8_t gray = graydata[i_gray];
//カラーパレットから色を取得
uint32_t newColor = ColorPalettes[currentPalettelndex][gray];
uint8_t r = (newColor >> 16) & 0xFF;
uint8_t g = (newColor >> 8) & 0xFF;
uint8_t b = newColor & 0xFF;
//バッファに書き込み BGRの順になる
int i_buffer = x * 3;
buffer[i_buffer] = b;
buffer[i_buffer + 1] = g;
buffer[i_buffer + 2] = r;
}
file.write(buffer, rowSize);
}
file.close();
} else {
LED[0] = CRGB::Red;
FastLED.show(); //Error!
}
}
void saveGraylevel_fromFb() {
uint8_t* fb_data = fb->buf;
int width = fb->width;
int height = fb->height;
int i = 0;
for (int y = 0; y < height; y++) {
for (int x = 0; x < (width * 2); x = x + 2) {
// 各ピクセルの色を取得
uint32_t rgb565Color = (fb_data[y * width * 2 + x] << 8) | fb_data[y * width * 2 + x + 1];
// RGB565からRGB888へ変換
uint32_t rgb888Color = canvas0.color16to24(rgb565Color);
uint8_t r = (rgb888Color >> 16) & 0xFF;
uint8_t g = (rgb888Color >> 8) & 0xFF;
uint8_t b = rgb888Color & 0xFF;
// 輝度の計算 BT.709の係数を使用
uint16_t luminance = (uint16_t)(0.2126 * r + 0.7152 * g + 0.0722 * b);
// 輝度を8階調のグレースケールに変換
uint8_t grayLevel = luminance / 32; // 256/32 = 8
// 輝度情報を保存
graydata[i] = grayLevel;
i++;
}
}
}
void saveGraylevel_fromCanvas(M5Canvas& srcSprite) {
int width = srcSprite.width();
int height = srcSprite.height();
int i = 0;
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
// 各ピクセルの色を取得
uint32_t rgb565Color = srcSprite.readPixel(x, y);
// RGB565からRGB888へ変換
uint32_t rgb888Color = srcSprite.color16to24(rgb565Color);
uint8_t r = (rgb888Color >> 16) & 0xFF;
uint8_t g = (rgb888Color >> 8) & 0xFF;
uint8_t b = rgb888Color & 0xFF;
// 輝度の計算 BT.709の係数を使用
uint16_t luminance = (uint16_t)(0.2126 * r + 0.7152 * g + 0.0722 * b);
// 輝度を16階調のグレースケールに変換
uint8_t grayLevel = luminance / 32; // 256/32 = 8
// 輝度情報を保存
graydata[i] = grayLevel;
i++;
}
}
}
void applyColorBayerDither4x4(M5Canvas& srcSprite, M5Canvas& dstSprite, int levelsPerChannel) {
// Bayerマトリックス
static const uint8_t bayer4x4[4][4] = {
{ 0, 8, 2, 10 }, { 12, 4, 14, 6 }, { 3, 11, 1, 9 }, { 15, 7, 13, 5 }
};
static const float bayerDivisor = 16.0f;
int width = dstSprite.width();
int height = dstSprite.height();
float step = 255.0f / (float)(levelsPerChannel - 1);
for (int y = 0; y < height; ++y) {
for (int x = 0; x < width; ++x) {
if (x >= srcSprite.width() || y >= srcSprite.height()) {
dstSprite.drawPixel(x, y, TFT_BLACK);
continue;
}
// 元画像のピクセル色を取得
uint32_t rgb565Color = srcSprite.readPixel(x, y);
uint32_t originalColorValue = srcSprite.color16to24(rgb565Color);
uint8_t r_src = (originalColorValue >> 16) & 0xFF;
uint8_t g_src = (originalColorValue >> 8) & 0xFF;
uint8_t b_src = originalColorValue & 0xFF;
// 各チャンネルに対してディザリングを適用
uint8_t r_dst, g_dst, b_dst;
uint8_t channels_src[3] = { r_src, g_src, b_src };
uint8_t channels_dst[3];
float bayerThreshold = (float)bayer4x4[y % 4][x % 4] / bayerDivisor;
for (int ch = 0; ch < 3; ++ch) {
uint8_t val_src = channels_src[ch];
int level_index = floor((float)val_src / step);
if (level_index >= levelsPerChannel - 1) { level_index = levelsPerChannel - 2; }
float level_low = (float)level_index * step;
float error = (float)val_src - level_low;
float normalized_error = (step > 0) ? (error / step) : 0.0f;
if (normalized_error < 0.0f) normalized_error = 0.0f;
if (normalized_error > 1.0f) normalized_error = 1.0f;
uint8_t val_dst;
if (normalized_error >= bayerThreshold) {
val_dst = (uint8_t)round(((float)level_index + 1.0f) * step);
} else {
val_dst = (uint8_t)round(level_low);
}
channels_dst[ch] = std::max(0, std::min(255, (int)val_dst));
}
r_dst = channels_dst[0];
g_dst = channels_dst[1];
b_dst = channels_dst[2];
// 新しいRGB値で出力先スプライトに描画
uint16_t ditheredColor = dstSprite.color565(r_dst, g_dst, b_dst);
dstSprite.drawPixel(x, y, ditheredColor);
}
// delay(0);
}
}
void setup() {
M5.begin();
pinMode(POWER_GPIO_NUM, OUTPUT);
digitalWrite(POWER_GPIO_NUM, LOW);
delay(500);
pinMode(KEY_PIN, INPUT_PULLUP);
FastLED.addLeds<SK6812, LED_PIN, GRB>(LED, 1);
LED[0] = CRGB::Red;
FastLED.setBrightness(200);
// 一度SDカードをマウントして確認
SPI.begin(7, 8, 6, -1);
if (!SD.begin(15, SPI, 10000000)) {
FastLED.show(); // エラー
delay(500);
return;
} else {
// パレット0から7までループ
for (int i = 0; i < 8; i++) {
if (loadPaletteFromSD(i)) {
//M5.Display.printf("Palette %d loaded from SD.\n", i);
} else {
//M5.Display.printf("Palette %d use default.\n", i);
}
delay(100);
}
}
SD.end(); // 一旦ENDしておく
if (psramFound()) {
camera_config.pixel_format = PIXFORMAT_RGB565;
camera_config.fb_location = CAMERA_FB_IN_PSRAM;
camera_config.fb_count = 2;
} else {
FastLED.show(); // エラー
delay(500);
}
if (!CameraBegin()) {
FastLED.show(); // エラー
delay(1000);
ESP.restart();
}
delay(500);
// ボタン押しながら起動でディザモード
if (!digitalRead(KEY_PIN)) {
dither = 1;
canvas0.createSprite(240, 176);
LED[0] = CRGB::Blue;
FastLED.setBrightness(200);
FastLED.show();
}
delay(500);
LED[0] = CRGB::LimeGreen;
FastLED.setBrightness(200);
FastLED.show();
}
void loop() {
if (!digitalRead(KEY_PIN)) {
keyOnTime = millis(); // 最後にkey操作した時間
LED[0] = CRGB::Orange;
FastLED.setBrightness(20);
FastLED.show();
CameraGet(); // 撮影
SD.end(); // 念のため一旦END
delay(100);
SD.begin(15, SPI, 10000000);
saveToSD_OriginalBMP(); // 変換前の画像保存
if (dither == 0) {
saveGraylevel_fromFb(); // 輝度情報の保存
} else {
canvas0.pushImage(0, 0, 240, 176, (uint16_t*)fb->buf); // (x, y, w, h, *data)
applyColorBayerDither4x4(canvas0, canvas0, levels);
saveGraylevel_fromCanvas(canvas0);
}
for (int i = 0; i < maxPalettelndex; i++) {
currentPalettelndex = i;
FastLED.setBrightness(i * 20 + 40); // 処理が進むごとに明るくする
FastLED.show();
saveToSD_ConvertBMP(); // 変換後の画像保存
}
CameraFree(); // フレームバッファを解放
filecounter++; // 連番を更新
SD.end();
LED[0] = CRGB::LimeGreen;
FastLED.setBrightness(200);
FastLED.show();
}
//一定時間操作していないとLEDをOFF 平均消費電流が45mAを下回るとTailBATが40秒後に自動OFFする
if ((millis() - keyOnTime >= LED_ON_DURATION)) {
LED[0] = CRGB::Black;
FastLED.show();
}
}
0x002B59
0x002B59
0x005F8C
0x005F8C
0x00B9BE
0x00B9BE
0x9FF4E5
0x9FF4E5
/*
・ATOMS3R カメラキット
https://www.switch-science.com/products/9916
https://shop.m5stack.com/products/atoms3r-camera-kit
・ATOMIC TFカードリーダー
https://www.switch-science.com/products/9423
https://shop.m5stack.com/products/atomic-tf-card-reader
・M5Stack用メカニカルキーボタンユニット
https://www.switch-science.com/products/8303
https://shop.m5stack.com/products/mechanical-key-button-unit
・ATOM TailBAT
https://www.switch-science.com/products/6348
https://shop.m5stack.com/products/atom-tailbat?variant=32169047064666
・LEGO テクニック 固定ピン 2本(Amazon 楽天などで入手可能)
https://amzn.to/4iItP41
・microSDカード 16GBまで
https://amzn.to/4hnl7XS
*/
@iNu0805
Copy link

iNu0805 commented Jul 12, 2025

ボード設定を変えて、ボードをM5AtomS3Rに変更しても
C:\Users\e6a250180e\Downloads\kamera\kamera.ino: In function 'void setup()':
C:\Users\e6a250180e\Downloads\kamera\kamera.ino:299:25: error: 'esp_spiram_get_size' was not declared in this scope
299 | size_t psram_size = esp_spiram_get_size() / 1048576;
| ^~~~~~~~~~~~~~~~~~~
exit status 1

Compilation error: 'esp_spiram_get_size' was not declared in this scope
というエラーメッセージが出てしまいます

@Yuikawa-Akira
Copy link
Author

Yuikawa-Akira commented Jul 13, 2025

size_t psram_size = esp_spiram_get_size() / 1048576;の行をコメントアウトしてみてください

//size_t psram_size = esp_spiram_get_size() / 1048576;

@iNu0805
Copy link

iNu0805 commented Jul 13, 2025

できました!!
本当にありがとうございます!!!!!

@kmbrhrt
Copy link

kmbrhrt commented Aug 20, 2025

書き込む際に以下のようなエラーが出てしまいます、何が原因でしょうか。
A fatal error occurred: Failed to connect to ESP32-S3: No serial data received.
For troubleshooting steps visit: https://docs.espressif.com/projects/esptool/en/latest/troubleshooting.html

Failed uploading: uploading error: exit status 2

@Yuikawa-Akira
Copy link
Author

内蔵の緑色のLEDが点灯するまで側面のリセットボタンを長押し(約2秒)してから離すとダウンロードモードになり、書き込み待機状態になるので試してみてください。
それでもダメならばCOMポートの番号が違うかもしれないので別のポート番号を選んでみてください。

@kmbrhrt
Copy link

kmbrhrt commented Aug 20, 2025

ダウンロードモードにして、シリアルポートをjdev/cu.usbmodem21101にすることで解決しました!ありがとうございます!
たくさん写真撮ってきます!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment