6
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

カラー電子ペーパーを使った個人ダッシューボード作成 2024 その2

Last updated at Posted at 2024-12-26

概要

以下の記事のアップデート版です

前回、カラー電子ペーパーを使ったダッシュボードを作成しましたが、急いで作ったこともあり個人的に納得の行く完成度ではなかったのと、記事が雑だったため全体的に見直して再作成しました

完成品はこちらです。2024年にふさわしいダッシュボードになった気がします

photo_stand.jpg

リポジトリはこちら

構成

プロジェクトの構成・処理としては次のとおりです

diagram.png

  1. 表示するダッシュボードをRemixを使ったWebページとして作成し、ホストします
  2. 作成したダッシュボードをplaywrightを利用して画像として取得します
  3. ディザリングを行います
  4. 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でダッシュボード画面を作成します。

image.png

前回同様、以下のサービスのAPIを利用しています

画像の取得

今回はClient側のpythonではなく、サーバー側で自分のホストしているページのSSを取得します。

今回は/image.pngにアクセスすると/dashboardのスクリーンショットを取得するようにします

[image.png].tsx
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に書いてもらいました)

dithering.ts

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にアクセスするとディザリング処理した画像を返すようにします

[dithered-image.png].tsx
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にアクセスすると以下のような画像が取得できます

dithered-image.png

pm2でサーバーの起動&永続化

画面が作成できたらダッシュボードのサーバーを永続化します。

必要に応じてpm2のconfigを作成します。今回はサーバーを3000番ポートで公開します。

pm2.config.cjs
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でサーバーを起動します

shell
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 # 設定保存

設定が終わったら一度再起動し、サーバーが永続化されていることを確認します。

shell
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以外を使う場合は適宜読み替えてください

shell
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
/boot/firmware/config.txt
- #dtparam=spi=on
+ dtparam=spi=on

保存したらraspberrypiを再起動します

システムライブラリ・パッケージのインストール

ダッシュボードへの描画用に以下のパッケージをインストールします

  • swig
  • liblgpio-dev
  • mise
  • uv
  • (python)
    • uvで作った仮想環境を使うなら不要
shell
# 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
shell
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 # 電子ペーパーの画像クリア

書き込んだ画像がこちら

raspi_and_epaper.jpg

cronを設定

動作確認ができたらcronで毎日0時あたりに電子ペーパーを更新します

shell
crontab -e
cron設定
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サイズのフォトスタンドであれば入りますが、少し小さいので厚紙等を切って枠を作るといい感じになります
厚紙の切り口が雑

photo_stand.jpg

邪魔なケーブルやRaspberry Piは裏側に止めておきます
完全に固定すると作業したいとき面倒なので今回はマジックテープで固定し、いつでも外せるようにしました

back.jpg

フォトスタンド、厚紙、マジックテープ全部ダイソーで買いました

これで電源を投入すれば完成です!

まとめ

前回の記事を全体的にアップデートし、納得行く出来の電子ペーパーダッシューボードが作成できました。

結構シンプルな構成にできたと思ってます。node.jsでのFloyd-SteinbergディザリングはバグがあったもののChatGPTが実装してくれたので助かりました。

今後の改善点

Server側にPlaywrightを持ってきたので、それを活かしてデータ取得したいです
特に、普段のタスク管理や習慣トラッカーにTickTickを使っているのですが、API v2が一般公開されておらず習慣トラッカーのデータをAPI経由で取得できないので、Playwrightを使ってWebスクレイピングで取得して表示すると楽しそうです

  1. GoogleカレンダーのAPIを使ってWebサイトに表示させる方法

  2. Raspberry Pi Zero(W, WH, 2 W)のセットアップ

6
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?