0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Pi Zero 2 WカメラをWeb化:遠隔撮影ボタン→画像表示&ダウンロードまで

0
Posted at

【Bookworm対応】Pi Zero 2 W + Camera Module v2(IMX219)で「電源ONで待受」→「遠隔から撮影指示」→「PC/スマホで画像取得」する(Flask + rpicam-still + systemd)
image.png

*撮影した画像

対象は Raspberry Pi Zero 2 Wです。Pico系(マイコン)ではありません。

0. この記事のゴール(誤解しやすいので先に正確化)

  • 電源ON → 自動でWebサーバ起動(待受)(systemdで自動起動)
  • 遠隔から撮影指示(ブラウザのボタン → Piが撮影)
  • 画像をPC/スマホで受け取る(ブラウザで表示&ダウンロード)

ここでいう「画像がPCに送られてくる」は、実装的には PC/スマホがHTTPで画像を取りに行くPull)です。
「撮影後にPiがPCへ勝手に転送(Push)」もできますが、別途(scp等の)転送機能が必要なので、後半でオプションとして載せます。

1. 重要な前提:ネットワークは「同じIP」ではなく「同じLAN」

よくある誤解ですが、同じIPにしてはいけません(衝突します)。
必要なのは 同一LAN同じWi-Fiや同じテザリング配下の同一セグメント)にいることです。

2.Bookwormのカメラ基盤を理解する

Bookworm世代のRaspberry Pi OSは libcamera が中核で、アプリ(rpicam-*)はlibcameraを通じてセンサーとISPを制御します。Raspberry Pi公式ドキュメントでも **「画像バッファはシステムメモリ上にあり、JPEG/H.264等のエンコーダへ渡せる」**ことが明記されています。
https://www.raspberrypi.com/documentation/computers/camera_software.html

さらにRaspberry Piのlibcamera実装は、AE/AWB/ALSCなどの画像処理アルゴリズム(IPA)を含みます(=自動露出・自動WB・周辺光量補正などは“勝手にやってくれる”が、収束に時間がかかることがある)。

そして超重要ポイント:

  • libcameraはgpu_memを使いません
  • フレームバッファはCMA(Contiguous Memory Allocator)領域に確保され、DMA-bufとして共有される
  • よって gpu_memを増やすのは基本的に逆効果(RAM 512MBのZero 2 Wでは特に痛い)

これはRaspberry Piフォーラムでもかなり明確に言い切られています。

3. ハード仕様メモ(速度議論の前提)

Pi Zero 2 W の内蔵Wi-Fiは 2.4GHz 802.11 b/g/n(5GHz非対応)です。

「テザリングが遅い」問題は、5GHzへ逃がすができない前提で考えます(後述)。

4.config.txt の場所(Bookwormでハマりがち)

Bookwormではconfig.txt/boot/firmware/config.txt にあります。公式docでも「boot partitionは /boot/firmware/」と明記されています。

またBookworm初期には/boot/config.txtがsymlinkだった時期があり、後に「案内用のプレースホルダ」に変わった経緯があります(古い手順記事が刺さらない原因)。

編集:

sudo nano /boot/firmware/config.txt

4.1まずは自動検出(推奨)

基本はこれでOKです。camera_auto_detect=1 は「認識できるCSIカメラなら自動でoverlayをロードする」設定です。

# Automatically load overlays for detected cameras
camera_auto_detect=1

4.2それでもダメな時だけ手動指定(切り分け用)

IMX219(Camera Module v2)を明示したい場合は、切り分け用としてこうします:

camera_auto_detect=0
dtoverlay=imx219

「手動指定するなら camera_auto_detect を 0 にして dtoverlay を足す」というパターンは、公式のカメラ設定説明でも同様の流れで案内されています(例:HQ/GS)。

変更は 再起動後に有効

5.カメラ動作確認(ヘッドレス前提で確実なやり方)

rpicam-helloは「プレビューを表示する」系で、GUI/DRM周りの条件で期待通りに見えないことがあります(X転送が効かない等も公式docに注意あり)。

ヘッドレスなら、まず静止画を書き出すのが確実。公式docの例もこれです:

rpicam-still --output test.jpg

rpicam-stillrpicam-apps群の一部。Bookwormでは libcamera- → rpicam- にリネームされました。
さらにrpicam-appsのREADMEでは旧名シンボリックリンクが削除されたことも明記されています(なので記事やコードは最初から rpicam-still 固定が安全)。

もしrpicam-stillが無いなら:

sudo apt update
sudo apt install -y rpicam-apps

6.実装:Flaskで「撮影指示API」+「画像配信」を作る

構成はシンプルに:

  • /:ブラウザUI(撮影ボタン・履歴表示)
  • POST /api/capture:撮影を実行してファイル名などをJSON返却
  • GET /images/<filename>:保存済み画像の取得(表示/ダウンロード)
  • systemdで常駐(電源ON=待受)

6.1 ディレクトリ作成

mkdir -p /home/pi/camera-app/{templates,static/images}
cd /home/pi/camera-app

6.2venv + 依存導入

asudo apt install -y python3-venv
python3 -m venv .venv
source .venv/bin/activate
pip install --upgrade pip
pip install flask gunicorn

6.3 app.py(全文)
/home/pi/camera-app/app.py

from __future__ import annotations

import os
import time
import datetime as dt
import threading
import subprocess
from pathlib import Path
from typing import Optional

from flask import Flask, render_template, jsonify, send_from_directory, request, abort

APP_DIR = Path(__file__).resolve().parent
IMAGE_DIR = APP_DIR / "static" / "images"
IMAGE_DIR.mkdir(parents=True, exist_ok=True)

# ---- 環境変数で調整できるパラメータ ----
BIND_HOST = os.getenv("CAMERA_BIND", "0.0.0.0")
PORT = int(os.getenv("CAMERA_PORT", "5000"))

CAP_WIDTH = int(os.getenv("CAP_WIDTH", "1280"))
CAP_HEIGHT = int(os.getenv("CAP_HEIGHT", "720"))

# 撮影までの待ち時間(ms)
# 小さいほど速いが、AE/AWBの収束前で露出・色が不安定になり得る(トレードオフ)
CAP_TIMEOUT_MS = int(os.getenv("CAP_TIMEOUT_MS", "200"))

# さらに低遅延にしたい場合:最初のフレームを即キャプチャ(画質安定と引き換え)
USE_IMMEDIATE = os.getenv("USE_IMMEDIATE", "0") == "1"

JPEG_QUALITY = int(os.getenv("JPEG_QUALITY", "90"))

# 画像が増えすぎないように上限
MAX_IMAGES = int(os.getenv("MAX_IMAGES", "200"))

# APIキー(未設定なら認証なし)
API_KEY = os.getenv("CAMERA_API_KEY", "").strip()

# (オプション)撮影後にscpでPCへPush転送
# 例: export PUSH_SCP_TARGET="user@192.168.1.10:/home/user/picam/"
PUSH_SCP_TARGET = os.getenv("PUSH_SCP_TARGET", "").strip()

# Bookwormでは rpicam-* を使う(旧名リンク削除もあり固定推奨)
CAM_CMD = "rpicam-still"

app = Flask(__name__)
_capture_lock = threading.Lock()


def _require_api_key_if_set() -> None:
    if not API_KEY:
        return
    key = request.headers.get("X-API-Key", "") or request.args.get("key", "")
    if key != API_KEY:
        abort(401)


def _cleanup_old_images() -> None:
    files = sorted(IMAGE_DIR.glob("*.jpg"), key=lambda p: p.stat().st_mtime, reverse=True)
    for p in files[MAX_IMAGES:]:
        try:
            p.unlink()
        except Exception:
            pass


def _scp_push(filepath: Path) -> Optional[str]:
    if not PUSH_SCP_TARGET:
        return None
    # systemd常駐でパスワード入力はできないので、鍵認証が前提
    try:
        subprocess.run(
            ["scp", "-q", str(filepath), PUSH_SCP_TARGET],
            check=True,
            timeout=30,
        )
        return None
    except Exception as e:
        return f"scp failed: {e}"


@app.get("/")
def index():
    _require_api_key_if_set()
    files = sorted(IMAGE_DIR.glob("*.jpg"), key=lambda p: p.stat().st_mtime, reverse=True)[:24]
    images = [p.name for p in files]
    return render_template("index.html", images=images, api_key_enabled=bool(API_KEY))


@app.post("/api/capture")
def api_capture():
    _require_api_key_if_set()

    # 多重起動でカメラが競合しやすいので排他
    if not _capture_lock.acquire(blocking=False):
        return jsonify({"status": "busy", "message": "capture already running"}), 409

    try:
        ts = dt.datetime.now().strftime("%Y%m%d_%H%M%S")
        filename = f"{ts}.jpg"
        filepath = IMAGE_DIR / filename

        cmd = [
            CAM_CMD,
            "--output", str(filepath),
            "--width", str(CAP_WIDTH),
            "--height", str(CAP_HEIGHT),
            "--quality", str(JPEG_QUALITY),
            "--timeout", str(CAP_TIMEOUT_MS),
            "--nopreview",
        ]
        if USE_IMMEDIATE:
            cmd.append("--immediate")

        t0 = time.time()
        subprocess.run(cmd, check=True, timeout=30)
        elapsed_ms = int((time.time() - t0) * 1000)

        _cleanup_old_images()

        push_err = _scp_push(filepath)

        return jsonify({
            "status": "success",
            "image": filename,
            "elapsed_ms": elapsed_ms,
            "push": bool(PUSH_SCP_TARGET),
            "push_error": push_err,
        })

    except subprocess.CalledProcessError as e:
        return jsonify({"status": "error", "message": f"camera command failed: {e}"}), 500
    except Exception as e:
        return jsonify({"status": "error", "message": str(e)}), 500
    finally:
        _capture_lock.release()


@app.get("/images/<path:filename>")
def get_image(filename: str):
    _require_api_key_if_set()
    return send_from_directory(IMAGE_DIR, filename)


@app.get("/api/health")
def health():
    return jsonify({
        "status": "ok",
        "camera_cmd": CAM_CMD,
        "width": CAP_WIDTH,
        "height": CAP_HEIGHT,
        "timeout_ms": CAP_TIMEOUT_MS,
        "immediate": USE_IMMEDIATE,
        "api_key": bool(API_KEY),
        "push": bool(PUSH_SCP_TARGET),
    })


if __name__ == "__main__":
    # 開発用。常用は systemd + gunicorn 推奨(後述)
    app.run(host=BIND_HOST, port=PORT, debug=False)

6.4templates/index.html(全文)
/home/pi/camera-app/app.py

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>Pi Zero 2 W Camera</title>
  <style>
    body { font-family: system-ui, sans-serif; margin: 24px; background: #f7f7f7; }
    .card { background: #fff; border-radius: 12px; padding: 16px; box-shadow: 0 2px 10px rgba(0,0,0,.08); }
    button { font-size: 18px; padding: 12px 18px; border: 0; border-radius: 10px; cursor: pointer; }
    .row { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
    .status { font-family: ui-monospace, monospace; white-space: pre-wrap; background: #111; color: #0f0; padding: 10px; border-radius: 8px; }
    img { max-width: 100%; border-radius: 12px; border: 1px solid #ddd; }
    .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; margin-top: 12px; }
    .thumb { background: #fff; border-radius: 12px; padding: 10px; border: 1px solid #eee; }
    .thumb a { display: inline-block; margin-top: 8px; }
    .warn { color: #b00; font-size: 14px; }
  </style>
</head>
<body>
  <div class="card">
    <h1>📸 Pi Zero 2 W Camera</h1>

    <div class="row">
      <button id="btn">撮影</button>
      <label>
        APIキー:
        <input id="key" type="password" placeholder="必要な場合のみ" />
      </label>
    </div>

    <p class="warn" id="warn"></p>

    <h3>状態</h3>
    <div class="status" id="status">ready</div>
  </div>

  <div class="card" style="margin-top:16px;">
    <h2>最新</h2>
    <div id="latest"></div>
  </div>

  <div class="card" style="margin-top:16px;">
    <h2>履歴(直近)</h2>
    <div class="grid" id="grid">
      {% for img in images %}
      <div class="thumb">
        <img src="/images/{{ img }}" alt="{{ img }}">
        <div>{{ img }}</div>
        <a href="/images/{{ img }}" download>download</a>
      </div>
      {% endfor %}
    </div>
  </div>

<script>
  const btn = document.getElementById('btn');
  const statusBox = document.getElementById('status');
  const latest = document.getElementById('latest');
  const grid = document.getElementById('grid');
  const keyBox = document.getElementById('key');
  const warn = document.getElementById('warn');

  async function capture() {
    btn.disabled = true;
    statusBox.textContent = "capturing...";
    warn.textContent = "";

    const headers = {};
    const key = keyBox.value.trim();
    if (key) headers["X-API-Key"] = key;

    try {
      const res = await fetch("/api/capture", { method: "POST", headers });
      const data = await res.json();
      statusBox.textContent = JSON.stringify(data, null, 2);

      if (!res.ok) {
        warn.textContent = "エラー: " + (data.message || res.statusText);
        return;
      }
      if (data.status !== "success") return;

      const imgUrl = "/images/" + data.image;

      latest.innerHTML = `
        <div class="thumb">
          <img src="${imgUrl}?t=${Date.now()}" alt="${data.image}">
          <div>${data.image} / ${data.elapsed_ms} ms</div>
          <a href="${imgUrl}" download>download</a>
        </div>
      `;

      const node = document.createElement("div");
      node.className = "thumb";
      node.innerHTML = `
        <img src="${imgUrl}?t=${Date.now()}" alt="${data.image}">
        <div>${data.image}</div>
        <a href="${imgUrl}" download>download</a>
      `;
      grid.prepend(node);

    } catch (e) {
      warn.textContent = "通信エラー: " + e;
      statusBox.textContent = String(e);
    } finally {
      btn.disabled = false;
    }
  }

  btn.addEventListener('click', capture);
</script>
</body>
</html>

7. まず手動で起動して動作確認

cd /home/pi/camera-app
source .venv/bin/activate
python3 app.py

PC/スマホから:

  • http://:5000/

8. 「電源ONで待受」を実現:systemd + gunicorn(ポート不整合も解消版)

Raspberry Pi公式ドキュメントでも、Bookwormではカメラ周りのCLIは rpicam-*を前提に整理されています(legacy stackは対象外)。
本記事は常駐運用するので、Flask開発サーバではなく gunicorn + systemdにします。

systemdの ExecStart= は「シェル展開しない」のが落とし穴。環境変数でPORT等を変えたいので bash -lc 経由にして事故を防ぎます。

8.1 環境ファイル

/etc/default/camera-web

CAMERA_BIND=0.0.0.0
CAMERA_PORT=5000
CAP_WIDTH=1280
CAP_HEIGHT=720
CAP_TIMEOUT_MS=200
JPEG_QUALITY=90
MAX_IMAGES=200

# 認証したいときだけ
# CAMERA_API_KEY=your-secret

# 低遅延(画質安定とトレードオフ)
# USE_IMMEDIATE=1

# 撮影後Push(scp)
# PUSH_SCP_TARGET=user@192.168.1.10:/home/user/picam/

8.2 serviceファイル
/etc/systemd/system/camera-web.service

[Unit]
Description=Pi Zero 2 W Camera Web (Flask + gunicorn)
Wants=network-online.target
After=network-online.target

[Service]
Type=simple
User=pi
WorkingDirectory=/home/pi/camera-app
EnvironmentFile=/etc/default/camera-web

ExecStart=/bin/bash -lc 'exec /home/pi/camera-app/.venv/bin/gunicorn -w 1 -b ${CAMERA_BIND}:${CAMERA_PORT} app:app'
Restart=on-failure
RestartSec=2

[Install]
WantedBy=multi-user.target

有効化:

sudo systemctl daemon-reload
sudo systemctl enable --now camera-web.service
sudo systemctl status camera-web.service --no-pager

ログ確認:

journalctl -u camera-web.service -e --no-pager

8.3 systemdハードニング例(任意)

LAN内でも「うっかり公開」を減らしたい場合、最低限のハードニングを足せます。画像保存ディレクトリだけ書き込み許可が必要なので ReadWritePaths= を使います。

NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/home/pi/camera-app/static/images

9. 「テザリング遅すぎる」問題:原因の切り分け指針(Pi側)

遅延の主因は、だいたいこの3つの合成です。
9.1 撮影までの待ち時間(AE/AWB収束)
Raspberry Piのlibcamera実装は AEC/AGC(自動露出/ゲイン)・AWB・ALSC等のIPAを含みます。
つまり「カメラを起動してから統計を取り、いい感じに落ち着く」まで少し時間が要ることがあります。
対策:

  • CAP_TIMEOUT_MS を小さくする(速いが不安定になりうる)
  • さらに詰めるなら USE_IMMEDIATE=1(最初のフレームを即キャプチャ。画質安定とトレードオフ)

9.2 無線品質(2.4GHzの混雑/距離/遮蔽物/スマホ側省電力)
Zero 2 Wは 2.4GHzのみなので、混雑の影響を受けやすいです。
対策(効く順の体感):

  • Piとスマホ/APを近づける
  • APのチャネル固定(2.4GHzは干渉地獄になりがち)
  • USB有線LAN化(OTG + USB Ethernet)
  • どうしてもテザリングなら、スマホ側の省電力設定を見直す

9.3 解像度・JPEG品質が高すぎる
1枚のJPEGが重いほど、転送も描画も遅くなります。

  • まず1280×720 / quality 85〜90 で安定化
  • その後に上げる

10. 遅延測定を“統計”でやる(elapsed_msの分布を見る)
UIには elapsed_ms を返しています。これを「n回叩いて」中央値やp95を見ておくと、体感より判断がブレません。
Piじゃなくても、PC側から以下みたいに叩けます(例:Python)。※Qiita貼り付け用のサンプル:

import statistics, time, requests

BASE = "http://<PiのIP>:5000"
N = 30

vals = []
for i in range(N):
    r = requests.post(f"{BASE}/api/capture", timeout=60)
    r.raise_for_status()
    j = r.json()
    vals.append(j["elapsed_ms"])
    time.sleep(0.2)

vals_sorted = sorted(vals)
print("N=", N)
print("min =", min(vals))
print("median =", statistics.median(vals))
print("mean =", statistics.mean(vals))
print("p95 =", vals_sorted[int(N*0.95)-1])
print("max =", max(vals))

ここで:

  • median は「いつもの速さ」
  • p95 は「たまに遅い」の上限感

を表します。

11.画像の自動ローテーション設計(ディスク寿命対策)

すでに MAX_IMAGESで古いJPEGを削除していますが、SDカード運用ならもう一段やる価値があります。
11.1 画像をtmpfs(RAM)に置く案(短時間用途向け)

「画像は一時的に見られればOK」で、再起動で消えてよいなら、/run 配下に逃がすと書き込み激減します(ただしRAM 512MBなので容量は控えめに)。

例:IMAGE_DIR/run/camera-images に変える、または /home/pi/camera-app/static/images をtmpfsでマウント。

11.2 systemd-tmpfilesで定期削除(消し忘れ対策)
運用で事故るのは「想定外に溜まって死ぬ」なので、保険としてtmpfilesを入れるのも手です

12.(オプション)本当に「PCへ送られてくる(Push)」をやる:scp転送

app.pyPUSH_SCP_TARGETを設定すると撮影後にscp転送します。

12.1 鍵認証を必ず作る(パスワード入力は常駐で詰まる)

Piで:

ssh-keygen -t ed25519 -N "" -f ~/.ssh/id_ed25519
ssh-copy-id user@192.168.1.10

/etc/default/camera-webに:

PUSH_SCP_TARGET=user@192.168.1.10:/home/user/picam/

を入れて再起動:

sudo systemctl restart camera-web.service

Windowsへ送る場合は、Windows側でOpenSSH Serverを有効化するか、受け取り手段をSMB共有などに変えるのが現実的です(OS別に節を分けると親切)。

13. セキュリティ注意

  • これはLAN内の簡易カメラとして作っています
  • ルータで外部公開(ポート開放)しない
  • どうしても公開するなら、最低でも
    • CAMERA_API_KEY を使う
    • VPN(WireGuard/Tailscale等)で閉域化
    • 可能ならTLS/リバプロ/アクセス制限を入れる

Bookwormの公式ドキュメントでも、カメラのアプリ群やスタックが「legacyではない」前提に整理されているので、古い手順raspistill等)をそのままインターネット公開運用に流用しないほうが安全です。


必要なら、この構成をベースにして:

  • 撮影キュー(連打しても順番に処理)
  • 画像ファイル名にUUID付与(同秒連打の衝突回避)
  • HTTPレスポンスにETag/Cache-Controlを入れて転送最適化
  • nginxリバースプロキシ + Basic認証/TLS(LAN外に出す前提)

みたいな、もう一段“運用品質”に寄せた内容を今後かんがえても良いかもしれません

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?