Raspberry Pi 5とUSBカメラで人検知し「混雑度」表示するWebアプリを作ってみた

日常業務では、「今この場所がどれくらい混んでいるか」をざっくり把握したい場面があります。たとえば、会議室前、共有席エリア、受付前、休憩スペース、イベント会場の入口などでは、厳密な人数管理までしなくても、「空いている」「少し混んでいる」「かなり混んでいる」といった状態が分かるだけでも便利です。

そこで今回も、「エレクトロニクスを簡単に」をテーマとしてRaspberry Pi 5上で動くWebアプリベースの混雑度表示アプリを試作してみました。USBカメラから取得した映像をローカルで処理し、人の数を数えて「混雑度」をブラウザ上に表示するシンプルな構成です。

ポイントは、ローカル環境で処理を完結しやすいことです。クラウドへ映像を送る前段で Raspberry Pi 5上に小さな混雑度判定の仕組みを置ければ、PoCとして始めやすく、あとからダッシュボード連携や通知機能へも発展しやすくなります。今回はまず、USBカメラ映像から人物を検出し、人数に応じて「空き」 「やや混雑」「混雑」「非常に混雑」を表示するPoCとしてまとめました。Raspberry Pi 5 公式 YOLOX README

目次

はじめに: 空いているかどうかをすぐ見たい

会議室前や共有スペースの利用状況を知りたいとき、毎回中を見に行ったり、誰かに確認したりするのは少し手間です。もちろん厳密な人数カウントや入退室管理まで行う方法もありますが、まずは「今は空いているか」「少し人が多いか」をざっくり把握できるだけでも、運用上は十分役立つことがあります。

そこで今回は、USBカメラの映像を Raspberry Pi 5で受け取り、人物を検出して混雑度を表示する小さなWebアプリを試作しました。PoCとして目指したのは、

  • ブラウザから現在の映像と状態を確認できる

  • 人数に応じて混雑度ラベルを表示できる

  • ローカル環境で処理を完結しやすい

  • 後から通知やログ保存へ広げやすい

という、小さく始めて拡張しやすい構成です。

Webアプリの基本構成

今回の構成は、Raspberry Pi 5上で Flask ベースのWebアプリを動かし、USBカメラの映像を OpenCV で読み込み、ONNX Runtimeで人物検出を行い、人物数をもとに混雑度をブラウザ表示するものです。Flask は軽量なWebアプリケーションフレームワークとして広く使われており、PoCの骨格をシンプルに組み立てやすいのが利点です。
Flask Documentation
Flask License

ブラウザ側では、

  • 現在のカメラ映像の表示

  • 現在人数の表示

  • 混雑度ラベルの表示

  • 最終更新時刻の表示

を行います。

サーバー側では、

  • USB カメラから映像を読み込む

  • フレームごとに人物検出を行う

  • person クラスだけを人数として数える

  • 混雑度ラベルに変換する

  • MJPEG ストリームとしてブラウザへ返す

という流れにしました。

人物検出には、YOLOX-NanoONNX モデルを使います。YOLOX公式 README では、YOLOX-Nano が軽量モデルとして案内されており、またONNX Runtime 用のデモ READMEでは、事前生成済みの yolox_nano.onnx も公開されています。
YOLOX README
YOLOX ONNXRuntime README

Raspberry Pi 5の特徴

今回のホストには Raspberry Pi 5を使いました。公式情報によると、Raspberry Pi 5は2.4GHz の Broadcom BCM2712 クアッドコア Cortex-A76 CPUを搭載し、Raspberry Pi 4と比べて2~3倍のCPU性能向上がうたわれています。さらに2基のUSB3.0ポート、Gigabit Ethernet、802.11ac Wi-Fi、Bluetooth 5.0なども備えており、映像入力と Web アプリをまとめて扱う小さなエッジ端末として使いやすい構成です。
Raspberry Pi 5 公式
Raspberry Pi 5 Product Brief PDF

今回のような用途では、単なるGPIO 制御ではなく、HTTPサーバー、映像入力、画像処理、ブラウザ表示を同居させることが重要になります。その意味で Raspberry Pi 5は、PoCを素早く立ち上げるための“小さなローカル映像処理サーバー”として扱いやすいバランスでした。
Raspberry Pi 5 公式

開発環境と実装方針

実装のベースは Python + Flask + OpenCV + ONNX Runtime です。USBカメラの映像取得は OpenCVの cv2.VideoCapture (0) を使い、最初のカメラデバイスを開く構成にしました。一般的に0は最初の /dev/video* デバイスに対応しますが、複数カメラがある場合は番号が変わることがあります。
GeeksforGeeks

モデルにはYOLOX-Nano を採用しています。公式 README では、YOLOX-Nano は軽量モデルとして掲載されており、0.91M Parameters / 1.08 GFLOPs / 416x416入力の構成です。また、README では ONNX export / ONNX Runtime と NCNNの利用も明記されているため、Raspberry Pi 上の推論実験に向いた選択肢です。
YOLOX README

また、画面上の日本語は OpenCV フレームに直接描かず、HTML側だけで表示する方針にしました。OpenCVの描画機能ではサポートされるフォントが限定的で、日本語を直接描くと文字化けや表示崩れの原因になりやすいためです。ブラウザ側 UIに日本語を寄せることで、この問題を避けやすくしています。
OpenCV Documentation

混雑度の定義は、今回はシンプルに以下のようにしました。

  • 0人 → 空き

  • 1~2人 → やや混雑

  • 3~4人 → 混雑

  • 5人以上 → 非常に混雑

もちろん実際には、カメラの画角や対象エリアの広さによって「何人で混雑とみなすか」は変わります。そのため、人数閾値はコード上で調整しやすい形にしています。

ディレクトリ構成

text

crowd-monitor-app/
├── app.py
├── requirements.txt
├── models/
│   └── yolox_nano.onnx
└── templates/
    └── index.html

今回は、アプリ本体を app.py にまとめ、ブラウザ UIを templates/index.html に置く最小構成にしています。モデルファイルは models/yolox_nano.onnx として配置します。

実装の考え方とサンプルコード

最小サンプルとして、Flask がMJPEGでカメラ映像を配信し、別エンドポイントで人数と混雑度の状態を返す構成にしています。ブラウザは /video_feed から映像を表示しつつ、/status を定期ポーリングして人数や混雑度を更新します。

また、フレーム上には人物の矩形だけを描画し、日本語ラベルはすべて HTML 側で表示することで、日本語の文字化けを避けています。
※本プログラムは生成AIを用いて実装しております。

app.py
python

from time import strftime
import cv2
import numpy as np
import onnxruntime as ort
from flask import Flask, Response, jsonify, render_template

app = Flask(__name__)

CAMERA_INDEX = 0
FRAME_WIDTH = 640
FRAME_HEIGHT = 480
MODEL_PATH = "models/yolox_nano.onnx"
INPUT_SIZE = 416
CONF_TH = 0.35
NMS_TH = 0.45

status = {
    "people": 0,
    "label": "初期化中",
    "color": "#64748b",
    "updated_at": "-"
}

cap = cv2.VideoCapture(CAMERA_INDEX)
cap.set(cv2.CAP_PROP_FRAME_WIDTH, FRAME_WIDTH)
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, FRAME_HEIGHT)

session = ort.InferenceSession(
    MODEL_PATH,
    providers=["CPUExecutionProvider"]
)
input_name = session.get_inputs()[0].name

def congestion(n: int):
    if n == 0:
        return "空き", "#16a34a"
    if n <= 2:
        return "やや混雑", "#eab308"
    if n <= 4:
        return "混雑", "#f97316"
    return "非常に混雑", "#dc2626"

def preprocess(frame, size=INPUT_SIZE):
    h, w = frame.shape[:2]
    scale = min(size / w, size / h)
    nw, nh = int(w * scale), int(h * scale)
    resized = cv2.resize(frame, (nw, nh))
    canvas = np.full((size, size, 3), 114, dtype=np.uint8)
    canvas[:nh, :nw] = resized
    x = canvas[:, :, ::-1].transpose(2, 0, 1).astype(np.float32)
    return x[None, ...], scale

def decode_yolox(output, size=INPUT_SIZE):
    strides = (8, 16, 32)
    grids, expanded = [], []
    for stride in strides:
        hsize, wsize = size // stride, size // stride
        xv, yv = np.meshgrid(np.arange(wsize), np.arange(hsize))
        grid = np.stack((xv, yv), axis=2).reshape(1, -1, 2)
        grids.append(grid)
        expanded.append(np.full((1, grid.shape[1], 1), stride))
    
    grids = np.concatenate(grids, axis=1)
    expanded = np.concatenate(expanded, axis=1)
    
    output = output.copy()
    output[..., :2] = (output[..., :2] + grids) * expanded
    output[..., 2:4] = np.exp(output[..., 2:4]) * expanded
    return output[0]

def nms(boxes, scores, thr=NMS_TH):
    if len(boxes) == 0:
        return []
    x1, y1, x2, y2 = boxes.T
    areas = (x2 - x1 + 1) * (y2 - y1 + 1)
    order = scores.argsort()[::-1]
    keep = []
    
    while order.size > 0:
        i = order[0]
        keep.append(i)
        xx1 = np.maximum(x1[i], x1[order[1:]])
        yy1 = np.maximum(y1[i], y1[order[1:]])
        xx2 = np.minimum(x2[i], x2[order[1:]])
        yy2 = np.minimum(y2[i], y2[order[1:]])
        
        w = np.maximum(0, xx2 - xx1 + 1)
        h = np.maximum(0, yy2 - yy1 + 1)
        inter = w * h
        iou = inter / (areas[i] + areas[order[1:]] - inter + 1e-6)
        
        order = order[1:][iou < thr]
    return keep

def detect_people(frame):
    x, scale = preprocess(frame)
    output = session.run(None, {input_name: x})[0]
    pred = decode_yolox(output)
    
    boxes = pred[:, :4]
    scores = pred[:, 4] * pred[:, 5]  # COCO person class only
    
    keep = scores >= CONF_TH
    boxes, scores = boxes[keep], scores[keep]
    
    if len(boxes) == 0:
        return []
        
    xyxy = np.empty_like(boxes)
    xyxy[:, 0] = boxes[:, 0] - boxes[:, 2]/2
    xyxy[:, 1] = boxes[:, 1] - boxes[:, 3]/2
    xyxy[:, 2] = boxes[:, 0] + boxes[:, 2]/2
    xyxy[:, 3] = boxes[:, 1] + boxes[:, 3]/2
    xyxy /= scale
    
    h, w = frame.shape[:2]
    xyxy[:, [0, 2]] = np.clip(xyxy[:, [0, 2]], 0, w - 1)
    xyxy[:, [1, 3]] = np.clip(xyxy[:, [1, 3]], 0, h - 1)
    
    keep_idx = nms(xyxy, scores)
    return xyxy[keep_idx].astype(int)

def gen_frames():
    while True:
        ok, frame = cap.read()
        if not ok:
            continue
            
        boxes = detect_people(frame)
        for x1, y1, x2, y2 in boxes:
            cv2.rectangle(frame, (x1, y1), (x2, y2), (0, 200, 0), 2)
            
        people = len(boxes)
        label, color = congestion(people)
        
        status["people"] = people
        status["label"] = label
        status["color"] = color
        status["updated_at"] = strftime("%Y-%m-%d %H:%M:%S")
        
        ok, buf = cv2.imencode(".jpg", frame, [int(cv2.IMWRITE_JPEG_QUALITY), 80])
        if not ok:
            continue
            
        yield (
            b"--frame\r\n"
            b"Content-Type: image/jpeg\r\n\r\n" + buf.tobytes() + b"\r\n"
        )

@app.get("/")
def index():
    return render_template("index.html")

@app.get("/video_feed")
def video_feed():
    return Response(
        gen_frames(),
        mimetype="multipart/x-mixed-replace; boundary=frame"
    )

@app.get("/status")
def get_status():
    return jsonify(status)

@app.get("/health")
def health():
    return jsonify({"ok": True})

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5000, debug=False, threaded=True)

templates/index.html
html

<!doctype html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>混雑度表示アプリ</title>
  <style>
    body {
      font-family: system-ui, sans-serif;
      background: #f7f8fb;
      color: #222;
      margin: 0;
      padding: 24px;
    }
    .container {
      max-width: 1000px;
      margin: 0 auto;
      background: #fff;
      border-radius: 16px;
      padding: 24px;
      box-shadow: 0 8px 30px rgba(0,0,0,0.08);
    }
    h1 { margin-top: 0; font-size: 1.8rem; }
    .summary { color: #555; margin-bottom: 20px; }
    .status-grid {
      display: grid;
      grid-template-columns: repeat(3, 1fr);
      gap: 16px;
      margin-bottom: 24px;
    }
    .card {
      border: 1px solid #e5e7eb;
      border-radius: 14px;
      padding: 16px;
      background: #fafafa;
    }
    .card h3 {
      margin: 0 0 8px;
      font-size: 0.95rem;
      color: #666;
      font-weight: 600;
    }
    .card .value {
      font-size: 1.6rem;
      font-weight: 700;
    }
    .badge {
      display: inline-block;
      padding: 8px 14px;
      border-radius: 999px;
      color: #fff;
      font-weight: 700;
    }
    .video-panel img {
      width: 100%;
      display: block;
      border-radius: 16px;
      border: 1px solid #e5e7eb;
      background: #111827;
    }
    .note {
      margin-top: 16px;
      color: #555;
      font-size: 0.95rem;
      line-height: 1.7;
    }
    @media (max-width: 800px) {
      .status-grid { grid-template-columns: 1fr; }
    }
  </style>
</head>
<body>
<div class="container">
  <h1>混雑度表示アプリ</h1>
  <p class="summary">
    USBカメラ映像から人物数を数え、現在の混雑度をローカル表示します。
  </p>
  
  <div class="status-grid">
    <div class="card">
      <h3>現在人数</h3>
      <div class="value" id="peopleCount">-</div>
    </div>
    <div class="card">
      <h3>混雑度</h3>
      <div class="value">
        <span class="badge" id="congestionBadge" style="background:#64748b;">初期化中</span>
      </div>
    </div>
    <div class="card">
      <h3>最終更新</h3>
      <div class="value" id="updatedAt" style="font-size:1rem;">-</div>
    </div>
  </div>

  <div class="video-panel">
    <img src="/video_feed" alt="USB camera live stream">
  </div>
  
  <p class="note">
    このPoCでは YOLOX-Nano の ONNX モデルを使い、人物の矩形だけを映像に重ねています。
    日本語はブラウザ側 UI に寄せることで、画面文字の文字化けを避けています。
  </p>
</div>

<script>
  async function updateStatus() {
    try {
      const res = await fetch("/status");
      const data = await res.json();
      
      document.getElementById("peopleCount").textContent = data.people;
      document.getElementById("updatedAt").textContent = data.updated_at;
      
      const badge = document.getElementById("congestionBadge");
      badge.textContent = data.label;
      badge.style.background = data.color;
    } catch(e) {
      console.error(e);
    }
  }
  
  updateStatus();
  setInterval(updateStatus, 1000);
</script>
</body>
</html>

requirements.txt
text

Flask>=3.1
numpy>=1.26
onnxruntime>=1.18
opencv-python-headless>=4.10

使い方

Raspberry Pi 5上でターミナルを起動し、crowd-monitor-app フォルダへ移動して、以下を実行して環境をセットアップしアプリを起動します。

Bash

python3 -m venv .venv
source .venv/bin/activate
pip install --upgrade pip setuptools wheel
pip install -r requirements.txt

mkdir -p models
wget -O models/yolox_nano.onnx \
  https://github.com/Megvii-BaseDetection/YOLOX/releases/download/0.1.1rc0/yolox_nano.onnx

python app.py

yolox_nano.onnx の配布先は、YOLOX公式の ONNX Runtime README で案内されている事前生成済み ONNX モデルです。
YOLOX ONNXRuntime README

起動後、同一ネットワーク上から http://<RaspberryPiのIPアドレス>:5000 にアクセスすると、Webアプリ画面が開きます。

操作の流れは以下です。

  1. USB カメラを Raspberry Pi 5 に接続する

  2. アプリを起動する

  3. ブラウザでアプリ画面を開く

  4. ライブ映像と人数、混雑度ラベルを確認する

今後の展望(改善案)

今回のPoCは、USBカメラ1台と軽量な人物検出モデルを使った簡易混雑度表示に絞っていますが、実運用を考えると改善余地はいくつかあります。

まず、関心領域 (ROI) を限定することで、実際に見たいエリアだけを対象にできます。通路を横切るだけの人や、背景の無関係な人物を除外したい場合には有効です。たとえば、共有席エリアの一角だけ、入口前だけ、といった使い方に向いています。

次に、入力サイズや閾値の調整です。今回のコードでは 416x416 入力、CONF_TH=0.35NMS_TH=0.45 を初期値にしていますが、設置場所によって最適値は変わります。誤検出が多いなら CONF_TH を上げる、見逃しが多いなら少し下げる、といった調整が有効です。

さらに、YOLOX公式 README ではONNXに加えてNCNNのデプロイ例も案内されています。Raspberry Pi での速度最適化を進めたい場合は、PoCでONNX Runtime 版を動かしたあと、NCNN版へ発展させる流れも取りやすそうです。
YOLOX README

現場運用を考えると、一定人数以上で通知する、時間帯別に混雑ログを残す、Web ダッシュボードへ集約する、複数カメラを切り替えるといった拡張も考えられます。最初はローカル表示だけでも、後からMQTTやHTTP API に接続すれば、見える化の幅を広げやすい構成です。

関連技術・参考トピック

今回使用した Flaskは、軽量な Web アプリケーションフレームワークで、PoCのような小規模な画面やAPIを素早く立ち上げやすいのが魅力です。
Flask Documentation

Flask License

また、OpenCV は画像処理と映像処理の基盤として広く使われており、USBカメラ入力、矩形描画、JPEG エンコードなどをまとめて扱えるため、Raspberry Pi 上の PoC でも導入しやすい構成を作りやすいです。日本語の UI 表示はブラウザへ寄せることで、OpenCV 側の文字レンダリング制約も回避しやすくなります。

OpenCV Documentation

今回採用した YOLOXは、Apache License 2.0 で公開されている物体検出モデル実装で、README では軽量なYOLOX-Nano、さらにONNX Runtime NCNN を含むデプロイパスも案内されています。会社の技術ブログで紹介する PoC としても、モデル選定の説明をしやすい構成です。

YOLOX LICENSE
YOLOX README
YOLOX ONNXRuntime README

また、OpenCVを活用した画像認識への対応として、RenesasはRZシリーズを展開しています。今回の作例では実装までは行っていませんが、今後、さまざまなアプリケーションへの展開を検討する際には、RZシリーズの活用も有効な選択肢になると感じました。

RZファミリ 32ビットおよび64ビット・マイクロプロセッサ(MPU)

まとめ

今回は、Raspberry Pi 5上で動く、USBカメラベースの混雑度表示 Web アプリを作ってみました。

構成自体はシンプルですが、

  • USBカメラ映像を取り込む

  • 人物を検出して人数を数える

  • 「空き」「やや混雑」「混雑」「非常に混雑」といった形で表示する

  • ローカル環境で完結しやすい

という流れを通して、今その場所がどれくらい混んでいるかをざっくり見える化する仕組みを小さく試せる題材になりました。

PoC としては、まずはカメラ位置やしきい値を調整しながら、「どの程度現場感に合うか」を見るだけでも十分価値があります。そこから必要に応じて、通知、記録、ROI指定、さらに高速化へと段階的に広げていくのが扱いやすい進め方だと感じました。

参考リンク

Raspberry Pi 5 公式ページ
Raspberry Pi 5 Product Brief PDF
Flask Documentation
Flask License
OpenCV Drawing Functions Documentation
GeeksforGeeks: Capture Video from Camera in OpenCV Python
YOLOX GitHub Repository
YOLOX Apache License 2.0
YOLOX README
YOLOX ONNXRuntime README
RZファミリ 32ビットおよび64ビット・マイクロプロセッサ(MPU)