カラー電子ペーパーでフォトフレームを作る
昔M5Paperに画像を送れるようなものを作ったんですよね。
最近はWaveshareの電子ペーパーの扱いにも慣れてきました。
で、Waveshareのラインアップを見ていると…こういうのがあるんですよね。7色カラー表示の電子ペーパー!
液晶のデジタルフォトフレームはすごくたくさんあるわけですが、電子ペーパーとなるとなかなかない。カラーの電子ペーパーがあれば電子ペーパーフォトフレームが出来るんじゃないか?
できた。
材料
カラー電子ペーパー
Waveshareから7.3インチ7色の電子ペーパーが出ています。AmazonやAliExpressにある。
マイコン
電子ペーパーカレンダーの時はカメラ付きのボードからカメラを毟って使いましたが、カメラなし、かつPSRAM 8MBのものを使おうとしてこれを選びました。
ただこっちはmicroSDリーダーがついていません。
microSDリーダー
フォトフレームだから写真を保存しておくストレージが必要ですよね。マイコンボードについていないので…外付けのmicroSDリーダーをブレッドボードに設置して使うことにしました。こういうやつ。
バッテリー
電子ペーパーカレンダーの時に使ったダイソーのモバイルバッテリーは、ESP32がスリープ中も給電してくれる点が最適です。ただ他にそういうバッテリーがないわけではなく、確認できたもう一種類がこれです。
ダイソーに比べればだいぶ高いですが、フォトフレームのスタンドに縛りつけるのに適した形状や、ケーブル内蔵という点は便利です。
材料費
直角ピンヘッダーはカレンダーにも使ってるんですが、計上するのを忘れていたのでこっちに入れました。他ワイヤーとか色々要る。
コード
本当はWebサーバーとして起動して写真をスマホから直接送るモードをつけたいのですが、今はまだmicroSDに保存してある写真を1時間ごとに切り替えて表示するだけです。
ただこの「表示する」が大問題ですよね。写真はフルカラーですがこの電子ペーパーは7色しかないので…ディザリング処理を入れる必要があります。WaveshareのWikiにはあらかじめPCでディザリングする用のコードが載っています。
でもスマホから直接写真を送ることを考えると、ESP32側でディザリング処理できるようにしたい。そのためにPSRAM付きのモデルを選んだので…!
Floyd–Steinbergアルゴリズム
そのような減色ディザリング処理に広く使われているのがFloyd–Steinbergアルゴリズムです。
元画像のピクセルに一番近い色を選ぶ
選んだ色と元の色の誤差を算出する
誤差に指定の係数を掛けて、まだ計算されていない周辺のピクセルにばらまく
次のピクセルに移動する
大体こんな流れで左上から右下にピクセルを処理していきます。例えば元画像では50%グレーのピクセルがあると、減色画像の同じ位置のピクセルは真っ黒になるかもしれませんが、灰色を黒として描画した誤差は保存されるので隣のピクセルは白になる…こういった処理を繰り返します。
const RGBColor RGB_BLACK = {0, 0, 0};
const RGBColor RGB_WHITE = {255, 255, 255};
const RGBColor RGB_GREEN = {0, 255, 0};
const RGBColor RGB_BLUE = {0, 0, 255};
const RGBColor RGB_RED = {255, 0, 0};
const RGBColor RGB_YELLOW = {255, 255, 0};
const RGBColor RGB_ORANGE = {255, 128, 0};
const RGBColor colorArray[7] = {RGB_BLACK, RGB_WHITE, RGB_GREEN, RGB_BLUE, RGB_RED, RGB_YELLOW, RGB_ORANGE};
struct ColorDiff
{
int RDiff;
int GDiff;
int BDiff;
};
こんな感じで電子ペーパーの7色がRGBではどの色に相当するか設定しておきます。
int nearestColor(RGBColor color)
{
int minDiff = 255 + 255 + 255;
int minIndex = 0;
for (int i = 0; i < 7; i++)
{
ColorDiff diff = diffColor(color, i);
int totalDiff = abs(diff.RDiff) + abs(diff.GDiff) + abs(diff.BDiff);
if (totalDiff < minDiff)
{
minDiff = totalDiff;
minIndex = i;
}
}
return minIndex;
}
ColorDiff diffColor(RGBColor color, int indexColor)
{
RGBColor tempColor = colorArray[indexColor];
ColorDiff diff = {0, 0, 0};
diff.RDiff = (int)(color.r) - (int)(tempColor.r);
diff.GDiff = (int)(color.g) - (int)(tempColor.g);
diff.BDiff = (int)(color.b) - (int)(tempColor.b);
return diff;
}
RGBColor totalColor(RGBColor color, ColorDiff diff)
{
RGBColor result;
int totalR = (int)(color.r) + diff.RDiff;
totalR = (totalR < 0) ? 0 : totalR;
result.r = (totalR > 255) ? 255 : totalR;
int totalG = (int)(color.g) + diff.GDiff;
totalG = (totalG < 0) ? 0 : totalG;
result.g = (totalG > 255) ? 255 : totalG;
int totalB = (int)(color.b) + diff.BDiff;
totalB = (totalB < 0) ? 0 : totalB;
result.b = (totalB > 255) ? 255 : totalB;
return result;
}
ColorDiff factorDiff(ColorDiff diff, int factorBy16)
{
ColorDiff tempDiff;
tempDiff.RDiff = diff.RDiff * factorBy16 / 16;
tempDiff.GDiff = diff.GDiff * factorBy16 / 16;
tempDiff.BDiff = diff.BDiff * factorBy16 / 16;
return tempDiff;
}
で…こんな感じで、一番近い色を返す関数、誤差を計算する関数、他のピクセルからばら撒かれた誤差を積算した色を計算する関数などを作っておきます。
void loadImage()
{
// Load JPEG file
SPI.end();
SPI.begin();
if (!SD.begin(5, SPI))
{
log_e("Card Mount Failed");
return;
}
Preferences pref;
pref.begin("6ColorPaper", false);
int number = pref.getShort("currentNumber");
number++;
char fileName[9];
sprintf(fileName, "/%03d.jpg", number);
if (!SD.exists(fileName))
{
number = 1;
sprintf(fileName, "/%03d.jpg", number);
if (!SD.exists(fileName))
{
log_e("No image found");
return;
}
}
pref.putShort("currentNumber", number);
File jpgFile = SD.open(fileName);
if (!jpgFile)
{
log_e("File load Failed");
return;
}
rawImage.setPsram(true);
rawImage.setColorDepth(24);
rawImage.createSprite(EPD_WIDTH, EPD_HEIGHT);
log_e("Sprite size:%d", rawImage.bufferLength());
rawImage.drawJpg(&jpgFile, 0, 0);
jpgFile.close();
SPI.end();
// Create dither
ditherImage.setPsram(true);
ditherImage.setColorDepth(4);
ditherImage.createSprite(EPD_WIDTH, EPD_HEIGHT);
for (int y = 0; y < EPD_HEIGHT; y++)
{
for (int x = 0; x < EPD_WIDTH; x++)
{
RGBColor color = rawImage.readPixelRGB(x, y);
int indexColor = nearestColor(color);
ditherImage.drawPixel(x, y, indexColor);
ColorDiff diff = diffColor(color, indexColor);
if (y + 1 < EPD_HEIGHT)
{
if (x > 0)
{
RGBColor bottomLeft = rawImage.readPixelRGB(x - 1, y + 1);
bottomLeft = totalColor(bottomLeft, factorDiff(diff, 3));
rawImage.drawPixel(x - 1, y + 1, bottomLeft);
}
RGBColor bottom = rawImage.readPixelRGB(x, y + 1);
bottom = totalColor(bottom, factorDiff(diff, 5));
rawImage.drawPixel(x, y + 1, bottom);
if (x + 1 < EPD_WIDTH)
{
RGBColor bottomRight = rawImage.readPixelRGB(x + 1, y + 1);
bottomRight = totalColor(bottomRight, factorDiff(diff, 1));
rawImage.drawPixel(x + 1, y + 1, bottomRight);
}
}
if (x + 1 < EPD_WIDTH)
{
RGBColor right = rawImage.readPixelRGB(x + 1, y);
right = totalColor(right, factorDiff(diff, 7));
rawImage.drawPixel(x + 1, y, right);
}
}
}
// Transfer to EPD
Epd epd;
if (epd.Init() != 0)
{
log_e("e-Paper init failed");
return;
}
log_e("e-Paper Clear");
epd.Clear(EPD_7IN3F_WHITE);
log_e("Show pic");
epd.EPD_7IN3F_Display_part((unsigned char *)(ditherImage.getBuffer()), 0, 0, EPD_WIDTH, EPD_HEIGHT);
delay(2000);
epd.Sleep();
}
実際に実装してみると結構簡単です。LovyanGFXを使ってSDカード上のJPEGファイルを24ビットカラーのバッファーに読み込みます。結果を格納する8ビットのバッファーも同じ幅と高さで作っておきます。あとは1ピクセルずつFloyd–Steinbergアルゴリズムに従って結果を計算していきます。
ちなみにカレンダーで使っている3色電子ペーパーの場合は黒と赤の色を表す2つの1ビットバッファーをそれぞれ転送します。しかし7色電子ペーパーの場合は4ビットのインデックスカラーとして1つのバッファーを転送するようになっています。
で、どんな感じになるの
例えば元写真はこんな感じです。
これをバキバキに彩度を上げて、800x480にリサイズ、JPEGとしてmicroSDに保存しておきます。
それを表示させるとこう。
事前に彩度を上げてようやくこういう色褪せたような感じになります。
他の例も見てみましょう。全体的に似通った色のグンディたちはどうでしょうか。
意外にちゃんと写真に見えます。寄ってみるとピクセルは見えますが…
暖色系は割と得意です。
逆に青色系はほぼ出ないです。これはまあしょうがない。
そんな感じで…液晶のフォトフレームと違って発光しないので、鮮やかではない代わりにいい感じの存在感の無さがあります。存在感の無さがあるってどういうことだよ。
Wi-Fiに繋がっていないのでバッテリーもかなり持ちます。1時間ごとに更新しても2週間くらい。あとは前処理含めてスマホから直接送信できるようにしたいですね。