概要
以下の記事のアップデート版です
前回、カラー電子ペーパーを使ったダッシュボードを作成しましたが、急いで作ったこともあり個人的に納得の行く完成度ではなかったのと、記事が雑だったため全体的に見直して再作成しました
完成品はこちらです。2024年にふさわしいダッシュボードになった気がします
リポジトリはこちら
構成
プロジェクトの構成・処理としては次のとおりです
- 表示するダッシュボードをRemixを使ったWebページとして作成し、ホストします
- 作成したダッシュボードをplaywrightを利用して画像として取得します
- ディザリングを行います
- Raspberry Piにて画像を取得し、電子ペーパーに書き込みます
前回はClient側でPlaywrightを立ち上げ、pythonのPillow
ライブラリを利用してディザリングを行っていましたが、今回はそれらの処理をServer側に移し、Clientの処理を電子ペーパーへの書き込みのみに簡略化しました。これによりRaspberry Pi Zeroでも安定して稼働できるようになりました。
利用したツール等
ハードウェア
- Waveshare 7.3inch e-Paper HAT (F)
- 7色描画可能な電子ペーパー
- Raspberry pi
- Raspberry pi Zero 2 WH もしくはRaspberry pi 4/5 を想定しています
- 本記事ではRaspberry pi Zero 2 WHを利用します
- 適当なサーバー(Raspberry pi Zero 2を利用する場合)
- 本記事ではServerとClientは分けていますが、Raspberry Pi 4/5等を利用すれば1台のRaspberry Piで動作可能です
ライブラリ・ソフトウェア
javscript / typescript
- bun
- ダッシュボードプロジェクトの開発・パッケージ管理に使います
- remix
- ダッシュボードのUIを作るフレームワークです
- pm2
- サーバーの永続化に使います
- playwright
- サーバーでホストしたダッシュボードのスクリーンショットを取得するのに使います
python
- uv
- pythonのパッケージ管理に使います
その他
- mise
- python, nodeのバージョン管理に利用しています
サーバーのセットアップ
システムライブラリ・パッケージのインストール
ダッシュボードのサーバー用に以下のパッケージをインストールします
- mise
- bun
- node
- pm2
- playwright
# miseのインストール
# https://mise.jdx.dev/getting-started.htmlを参照してください
curl https://mise.run | sh
# bunのインストール
mise use -g bun@latest
# node
mise use -g node@latest
# pm2のインストール
bun add -g pm2
# playwrightと依存パッケージのインストール
cd <project dir>
bun add playwright
bunx playwright install-deps
ダッシュボード画面の作成
remixを利用してダッシュボード画面を作成します。
電子ペーパーのサイズに合わせて800 x 480 pxでダッシュボード画面を作成します。
前回同様、以下のサービスのAPIを利用しています
-
OpenWeatherMap
- 天気予報の取得
-
GoogleCalenderAPI
- カレンダーイベントの取得
- こちら1の記事を参考にAPIキーを使った認証を利用しています
-
今日は何の日API
- 右上の記念日の表示に利用
画像の取得
今回はClient側のpythonではなく、サーバー側で自分のホストしているページのSSを取得します。
今回は/image.png
にアクセスすると/dashboard
のスクリーンショットを取得するようにします
import type { LoaderFunction } from "@remix-run/node";
import { chromium } from "playwright";
const CHROMIUM_FLAGS = [
"--disable-gpu",
"--disable-dev-shm-usage",
"--disable-setuid-sandbox",
"--no-first-run",
"--no-sandbox",
"--no-zygote",
"--single-process",
"--disable-audio-output",
"--disable-background-timer-throttling",
"--disable-backgrounding-occluded-windows",
"--disable-breakpad",
"--disable-extensions",
"--disable-sync",
"--disable-translate",
// '--virtual-time-budget=10000'
];
export const takeScreenshot = async (url: string) => {
const browser = await chromium.launch({ headless: true, args: CHROMIUM_FLAGS });
const context = await browser.newContext({
viewport: { width: 800, height: 480 },
deviceScaleFactor: 2,
});
const page = await context.newPage();
const port = process.env.SERVER_PORT || 3000;
await page.goto(`http://localhost:${port}/dashboard`);
const screenshot = await page.screenshot();
await browser.close();
return screenshot;
};
export const loader: LoaderFunction = async ({ request }) => {
const port = process.env.SERVER_PORT || 3000;
const url = `http://localhost:${port}/dashboard`;
img = await takeScreenshot(url);
return new Response(img, {
headers: {
"Content-Type": "image/png",
},
});
};
前回同様、CHROMIUM_FLAGSを設定して軽量化しています。
また、device_scale_factor=2
を設定し、縦横2倍の解像度でスクリーンショットを取得します。大きめの画像を利用することで後に縮小&ディザリング処理をしたとき、より綺麗な画像が得られます
画像の縮小&ディザリング処理
前回とは異なり、今回はbun (or node.js) で画像の縮小とディザリング処理を行います。
Pillow
と同様、Floyd-Steinberg法でディザリング処理をしようと思いましたがいい感じのライブラリがなかったので自作しました (ChatGPTに書いてもらいました)
import { Jimp, type JimpInstance, JimpMime } from "jimp";
type Color = { r: number; g: number; b: number };
const hexToRgb = (hex: string): Color => {
const bigint = Number.parseInt(hex.replace("#", ""), 16);
return {
r: (bigint >> 16) & 255,
g: (bigint >> 8) & 255,
b: bigint & 255,
};
};
// 最も近い色をパレットから探す
const findClosestColor = (color: Color, palette: Color[]): Color => {
let closestColor = palette[0];
let minDistance = Number.POSITIVE_INFINITY;
for (const paletteColor of palette) {
const distance =
(color.r - paletteColor.r) ** 2 + (color.g - paletteColor.g) ** 2 + (color.b - paletteColor.b) ** 2;
if (distance < minDistance) {
minDistance = distance;
closestColor = paletteColor;
}
}
return closestColor;
};
// Floyd-Steinberg法でディザリング
const floydSteinbergDithering = (image: any, palette: Color[]) => {
const width = image.bitmap.width;
const height = image.bitmap.height;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = (width * y + x) * 4;
// 現在のピクセルの色を取得
const oldPixel = {
r: image.bitmap.data[idx],
g: image.bitmap.data[idx + 1],
b: image.bitmap.data[idx + 2],
};
// 最も近い色をパレットから選択
const newPixel = findClosestColor(oldPixel, palette);
// エラーを計算
const error = {
r: oldPixel.r - newPixel.r,
g: oldPixel.g - newPixel.g,
b: oldPixel.b - newPixel.b,
};
// 現在のピクセルを更新
image.bitmap.data[idx] = newPixel.r;
image.bitmap.data[idx + 1] = newPixel.g;
image.bitmap.data[idx + 2] = newPixel.b;
// エラーを周囲のピクセルに分配
const distributeError = (dx: number, dy: number, factor: number) => {
const nx = x + dx;
const ny = y + dy;
if (nx >= 0 && nx < width && ny >= 0 && ny < height) {
const nIdx = (width * ny + nx) * 4;
// ****** ① ****** //
image.bitmap.data[nIdx] = Math.min(255, Math.max(0, image.bitmap.data[nIdx] + Math.round(error.r * factor)));
image.bitmap.data[nIdx + 1] = Math.min(
255,
Math.max(0, image.bitmap.data[nIdx + 1] + Math.round(error.g * factor)),
);
image.bitmap.data[nIdx + 2] = Math.min(
255,
Math.max(0, image.bitmap.data[nIdx + 2] + Math.round(error.b * factor)),
);
}
};
distributeError(1, 0, 7 / 16); // 右
distributeError(-1, 1, 3 / 16); // 左下
distributeError(0, 1, 5 / 16); // 下
distributeError(1, 1, 1 / 16); // 右下
}
}
};
// バッファから読み取り、バッファとして返す処理
export const ditherImageBuffer = async (inputBuffer: Buffer, paletteHex: string[]) => {
try {
const image = await Jimp.read(inputBuffer);
// 画像を縦横それぞれ半分に縮小
image.resize({ w: image.bitmap.width / 2, h: image.bitmap.height / 2 });
// パレットをRGBに変換
const palette = paletteHex.map(hexToRgb);
// ディザリング処理
floydSteinbergDithering(image, palette);
// バッファとして出力(PNG形式)
const outputBuffer = await image.getBuffer(JimpMime.png);
return outputBuffer;
} catch (err) {
console.error("Error processing image:", err);
throw err;
}
};
コード中の①の部分について、ChatGPTのコピペだとBufferの値がオーバーフローしたため、Math.min(255, Math.max(0, value));
のように0 ~ 255にクリッピングしていますが、正しいやり方なのかどうかは調べてません
画像見た感じでは問題ない気がしています
上記コードを使って、/dithered-image.png
にアクセスするとディザリング処理した画像を返すようにします
import type { LoaderFunction } from "@remix-run/node";
import DCache from "~/features/cache/cache";
import { ditherImageBuffer } from "~/features/dithering/dithering";
import { takeScreenshot } from "~/features/screenshot/takeScreenshot";
export const loader: LoaderFunction = async ({ request }) => {
const port = process.env.SERVER_PORT || 3000;
const url = `http://localhost:${port}/dashboard`;
const ss = await takeScreenshot(url);
const palette = [ // Waveshare 7.3inch e-Paper HAT (F)のカラーパレット
"#FF0000", // Red
"#00FF00", // Green
"#0000FF", // Blue
"#FFFF00", // Yellow
"#FF8000", // Orange
"#000000", // Black
"#FFFFFF", // White
];
const dithredImg = await ditherImageBuffer(ss, palette);
return new Response(dithredImg, {
headers: {
"Content-Type": "image/png",
},
});
};
/dithered-image.png
にアクセスすると以下のような画像が取得できます
pm2でサーバーの起動&永続化
画面が作成できたらダッシュボードのサーバーを永続化します。
必要に応じてpm2のconfigを作成します。今回はサーバーを3000番ポートで公開します。
module.exports = {
apps: [
{
name: "dashboard",
script: "remix-serve",
args: "build/server/index.js",
autorestart: true,
restart: "on-failure",
error_file: "/dev/null",
out_file: "/dev/null",
env: { PORT: 3000, NODE_ENV: "production" },
node_args: "--env-file .env",
},
],
};
設定ファイルを作ったらpm2でサーバーを起動します
bun run build # ビルド
pm2 start pm2.config.cjs
pm2 startup
# 次のようなスタートアップの設定用スクリプトが表示されるのでそれをコピー&ペーストして実行
# [PM2] To setup the Startup Script, copy/paste the following command:
# sudo env PATH=$PATH:/home/<username>/.nvm/versions/node/v22.12.0/bin /home/<username>/.bun/install/global/node_modules/pm2/bin/pm2 startup systemd -u <username> --hp /home/<username>
pm2 save # 設定保存
設定が終わったら一度再起動し、サーバーが永続化されていることを確認します。
pm2 ls
# ┌────┬────────────────────┬──────────┬──────┬───────────┬──────────┬──────────┐
# │ id │ name │ mode │ ↺ │ status │ cpu │ memory │
# ├────┼────────────────────┼──────────┼──────┼───────────┼──────────┼──────────┤
# │ 0 │ dashboard │ fork │ 0 │ online │ 0% │ 165.2mb │
# └────┴────────────────────┴──────────┴──────┴───────────┴──────────┴──────────┘
最後に、PCからもアクセスし接続できることを確認します。
Raspberry pi のセットアップ
Raspberry pi Imagerを利用してマウス、キーボード、モニタなしでセットアップします。
sshでアクセスできるようにしておきます。
セットアップ手順はこちら2の記事を参考にしました。
初回のセットアップが完了したら、電子ペーパーを使うための設定、ライブラリをインストールします
vimのインストール(任意)
vim-tinyに慣れていないのでvimをインストールします。vim以外を使う場合は適宜読み替えてください
sudo apt-get --purge remove vim-common vim-tiny
sudo apt-get install vim
spi通信の有効化
/boot/firmware/config.txt
を編集しspi通信を有効化します
sudo vim /boot/firmware/config.txt
- #dtparam=spi=on
+ dtparam=spi=on
保存したらraspberrypiを再起動します
システムライブラリ・パッケージのインストール
ダッシュボードへの描画用に以下のパッケージをインストールします
- swig
- liblgpio-dev
- mise
- uv
- (python)
- uvで作った仮想環境を使うなら不要
# swig, liblgpio-devのインストール
sudo apt-get install swig liblgpio-dev
# miseのインストール
# https://mise.jdx.dev/getting-started.htmlを参照してください
curl https://mise.run | sh
# uvのインストール
mise use -g uv@latest
# pythonのインストール
mise use -g python@latest
電子ペーパーへの書き込み
電子ペーパーへの書き込みにはwaveshare-epaperパッケージが利用できます。
from PIL import Image
import epaper
def draw(model: str, img: Image.Image):
try:
epd = epaper.epaper(model).EPD()
epd.init()
epd.display(epd.getbuffer(img))
epd.sleep()
except IOError as e:
print(e)
except KeyboardInterrupt:
epaper.epaper(model).epdconfig.module_exit(cleanup=True)
exit()
def clear(model: str):
try:
epd = epaper.epaper(model).EPD()
epd.init()
epd.Clear()
epd.sleep()
except IOError as e:
print(e)
except KeyboardInterrupt:
epaper.epaper(model).epdconfig.module_exit(cleanup=True)
exit()
サンプルプロジェクトの場合はpackages/drawer
を使って画像を取得し電子ペーパーへの書き込みまで実行できます。こちらはgpioピンを使ったspi通信等を行うためRaspberry piでのみライブラリのインストール&実行が可能です。
実行できない場合は前述した以下のライブラリがインストールされていることを確認してください
- swig
- lgpio-dev
cd epdash/packages/drawer
uv sync
echo "DASHBOARD_URL=\"http://<server ip address>:<port number>/dithered-image.png\"" > .env
uv run draw-dashboard.py # 電子ペーパーへの書き込み
uv run clear.py # 電子ペーパーの画像クリア
書き込んだ画像がこちら
cronを設定
動作確認ができたらcronで毎日0時あたりに電子ペーパーを更新します
crontab -e
PATH=<'echo $PATH' したものと同じPATHを設定>
0 0 * * * cd <path to project dir> && uv run draw-calendar.py
cronが動かない場合はcronの実行ログやPATHの設定を確認してください。
参考:cronの実行ログは以下のコマンドで確認できます
sudo journalctl -f -u cron
見た目を良くする
最後に、適当なフォトスタンドに入れて完成です
大きさ的には2Lサイズのフォトスタンドであれば入りますが、少し小さいので厚紙等を切って枠を作るといい感じになります
厚紙の切り口が雑
邪魔なケーブルやRaspberry Piは裏側に止めておきます
完全に固定すると作業したいとき面倒なので今回はマジックテープで固定し、いつでも外せるようにしました
フォトスタンド、厚紙、マジックテープ全部ダイソーで買いました
これで電源を投入すれば完成です!
まとめ
前回の記事を全体的にアップデートし、納得行く出来の電子ペーパーダッシューボードが作成できました。
結構シンプルな構成にできたと思ってます。node.jsでのFloyd-SteinbergディザリングはバグがあったもののChatGPTが実装してくれたので助かりました。
今後の改善点
Server側にPlaywrightを持ってきたので、それを活かしてデータ取得したいです
特に、普段のタスク管理や習慣トラッカーにTickTickを使っているのですが、API v2が一般公開されておらず習慣トラッカーのデータをAPI経由で取得できないので、Playwrightを使ってWebスクレイピングで取得して表示すると楽しそうです