Raspberry Pi 5でWebアプリベースのプライバシー保護(自動モザイク)アプリを作ってみた

日常業務では、写真を共有したり保存したりする前に、「人物の顔が写っている」「車のナンバープレートが読めてしまう」といった個人情報の扱いが気になる場面があります。たとえば、現場写真、イベントレポート、駐車場の記録画像、社内向けの作業メモなどは、そのまま扱うには少し配慮が必要です。

そこで今回も「エレクトロニクスを簡単に」をテーマとして、Raspberry Pi 5上で動く Webアプリベースの個人情報保護アプリ を試作してみました。ブラウザに画像をドラッグ&ドロップすると、ナンバープレート といった選択対象に対してモザイクをかけて出力する、シンプルな構成です。

ポイントは、ローカル環境で処理を完結しやすいことです。画像を外部サービスへ送る前に Raspberry Pi 5 上で匿名化できれば、元画像の取り扱いをより慎重にしやすくなります。今回は、まずは 画像1枚をブラウザから投入して、対象を選んでモザイクをかけるPoC としてまとめました。

目次

はじめに:写真共有前の“ひと手間”を減らしたい

写真をそのまま共有すると、意図せず個人情報が写り込んでいることがあります。イベント写真の参加者の顔、駐車場写真のナンバープレート、背景に映った名札や設備ラベルなどは、共有相手や用途によっては見せたくない情報になり得ます。

もちろん画像編集ソフトで手作業のマスキングもできますが、毎回それを行うのは手間です。そこで今回は、ブラウザに画像を投げ込むだけで、指定した対象にモザイクをかける仕組みを、Raspberry Pi 5 上で動くローカルWebアプリとして試作しました。

PoCとして目指したのは、

・ブラウザから画像を扱える
・モザイク対象を選べる
・ローカル環境で完結しやすい
・後から対象種別を増やしやすい

という、小さく始めて広げやすい構成です。

Webアプリの基本構成

今回の構成は、Raspberry Pi 5 上で Flask ベースのWebアプリを動かし、ブラウザから画像をアップロードしてサーバー側で匿名化処理を行うものです。Flask は BSD-3-Clause、OpenCV は Apache License 2.0 で公開されており、PoC の骨格を比較的扱いやすいOSSで組み立てやすいのが利点です。 flask.palletsprojects.com OpenCV License

ブラウザ側では、

・画像のドラッグ&ドロップ
・モザイク対象の選択
・処理結果のプレビュー
・匿名化済み画像のダウンロード

を行います。

サーバー側では、

・受け取った画像を読み込む
顔検出
ナンバープレート検出
・検出領域へのモザイク処理
・加工後画像の返却

という流れにしました。

顔検出は、今回は OpenCV の Haar Cascade を使う方針にしました。実装が比較的軽く、Raspberry Pi 上でも試しやすく、追加ビルドが不要な構成にしやすいためです。ナンバープレート検出については、アプリ本体から切り離した 差し替え式のアダプタ として設計し、将来的に Apache 2.0 ライセンスの PaddleDetectionなどで構築した検出器に差し替えられるようにしています。GitHub

Raspberry Pi 5の特徴

今回のホストには Raspberry Pi 5 を使いました。公式情報によると、Raspberry Pi 5 は 2.4GHz の Broadcom BCM2712 クアッドコア 64-bit Arm Cortex-A76 CPU を搭載し、メモリは 1GB / 2GB / 4GB / 8GB / 16GB のバリエーションがあります。さらに VideoCore VII GPUOpenGL ES 3.1 / Vulkan 1.3対応、PCIe 2.0 x1 なども備えており、Linux 上で Web アプリと画像処理をまとめて動かす土台として扱いやすい構成です。 Raspberry Pi

今回のような用途では、単なるGPIO制御ではなく、HTTPサーバー、画像処理ライブラリ、検出ロジック、ファイル入出力を同居させることが重要になります。その意味で Raspberry Pi 5 は、PoC を素早く立ち上げるための “小さなエッジサーバー” としてちょうどよいバランスでした。

開発環境と実装方針

実装のベースは Python + Flask + OpenCV です。Flask の公式ドキュメントではmultipart/form-data によるファイルアップロードsecure_filename のような基本対策MAX_CONTENT_LENGTH によるサイズ制限などが整理されています。 Flask Documentation

顔検出を OpenCV ベースに切り替えた理由は、Raspberry Pi 上でインストールしやすく、再現性を確保しやすいためです。OpenCV は画像処理の基盤として広く使われており、平滑化、リサイズ、色空間変換など匿名化アプリに必要な処理もまとめて扱えます。 OpenCV License
OpenCV Tutorial

モザイク処理そのものは OpenCV で行いました。今回は 対象領域を一度小さく縮小してから最近傍補間で引き伸ばす ことで、いわゆるピクセル化のモザイクを実装しています。OpenCV には cv2.GaussianBlur() を含む複数の平滑化手法があるため、将来的には「モザイク」「ぼかし」「黒塗り」を切り替える構成にも広げやすいです。 OpenCV Tutorial

ナンバープレート検出は、今回はWebアプリ本体から切り離した差し替え式の構成にしています。モデル重みやデータセット条件に合わせてplate_detector.py を実装する形を想定しています。PaddleDetection は Python 推論やONNX出力など、アプリ統合に向けた導線を用意しています。 GitHub

ディレクトリ構成

今回の最小構成は以下のようにしました。

text

privacy-mosaic-app/
├─ app.py
├─ plate_detector.py
├─ requirements.txt
└─ templates/
   └─ index.html

plate_detector.py は、ナンバープレート検出器を差し替えるためのアダプタです(今回は未実装)。

サンプルコード

最小サンプルとして、アプリ本体と検出器依存を分離した構成にしています。顔検出は OpenCV で実装し、ナンバープレート検出は plate_detector.py 側に切り出しています。

app.py

from io import BytesIO

import cv2
import numpy as np
from flask import Flask, jsonify, render_template, request, send_file

from plate_detector import detect_plates

app = Flask(__name__)
app.config["MAX_CONTENT_LENGTH"] = 10 * 1024 * 1024  # 10MB

ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "webp"}

face_cascade = cv2.CascadeClassifier(
    cv2.data.haarcascades + "haarcascade_frontalface_default.xml"
)


def allowed_file(filename: str) -> bool:
    return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS


def clamp_box(x1, y1, x2, y2, width, height):
    x1 = max(0, min(x1, width - 1))
    y1 = max(0, min(y1, height - 1))
    x2 = max(0, min(x2, width))
    y2 = max(0, min(y2, height))
    return x1, y1, x2, y2


def mosaic_region(image, x1, y1, x2, y2, blocks=12):
    roi = image[y1:y2, x1:x2]
    if roi.size == 0:
        return

    h, w = roi.shape[:2]
    small_w = max(1, w // blocks)
    small_h = max(1, h // blocks)

    small = cv2.resize(roi, (small_w, small_h), interpolation=cv2.INTER_LINEAR)
    mosaic = cv2.resize(small, (w, h), interpolation=cv2.INTER_NEAREST)
    image[y1:y2, x1:x2] = mosaic


def detect_faces(image_bgr):
    height, width = image_bgr.shape[:2]
    gray = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2GRAY)

    faces = face_cascade.detectMultiScale(
        gray,
        scaleFactor=1.1,
        minNeighbors=5,
        minSize=(30, 30)
    )

    boxes = []
    for (x, y, w, h) in faces:
        pad_x = int(w * 0.10)
        pad_y = int(h * 0.15)

        x1, y1, x2, y2 = clamp_box(
            x - pad_x,
            y - pad_y,
            x + w + pad_x,
            y + h + pad_y,
            width,
            height
        )
        boxes.append((x1, y1, x2, y2))

    return boxes


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


@app.post("/process")
def process():
    if "image" not in request.files:
        return jsonify({"error": "画像ファイルがありません"}), 400

    file = request.files["image"]
    targets = request.form.getlist("targets")

    if not file or file.filename == "":
        return jsonify({"error": "画像が選択されていません"}), 400

    if not allowed_file(file.filename):
        return jsonify({"error": "対応していない拡張子です"}), 400

    if not targets:
        return jsonify({"error": "モザイク対象を1つ以上選択してください"}), 400

    binary = np.frombuffer(file.read(), np.uint8)
    image = cv2.imdecode(binary, cv2.IMREAD_COLOR)

    if image is None:
        return jsonify({"error": "画像の読み込みに失敗しました"}), 400

    output = image.copy()

    if "face" in targets:
        for x1, y1, x2, y2 in detect_faces(output):
            mosaic_region(output, x1, y1, x2, y2, blocks=14)

    if "plate" in targets:
        for x1, y1, x2, y2 in detect_plates(output):
            mosaic_region(output, x1, y1, x2, y2, blocks=10)

    ok, encoded = cv2.imencode(".jpg", output, [cv2.IMWRITE_JPEG_QUALITY, 92])
    if not ok:
        return jsonify({"error": "出力画像の生成に失敗しました"}), 500

    return send_file(
        BytesIO(encoded.tobytes()),
        mimetype="image/jpeg",
        as_attachment=True,
        download_name="masked_output.jpg"
    )


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


plate_detector.py

# ナンバープレート検出器をここで差し替える構成

from typing import List, Tuple
import numpy as np

Box = Tuple[int, int, int, int]

def detect_plates(image_bgr: np.ndarray) -> List[Box]:
    """
    ここでは最小サンプルとして空配列を返している。
    実運用では、社内確認済みのモデル重み・学習データ条件に基づいて
    PaddleDetection などの推論結果を [x1, y1, x2, y2] の配列で返す。
    """
    _ = image_bgr
    return []


templates/index.html

<!doctype html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Privacy Mosaic App</title>
  <style>
    body {
      font-family: system-ui, sans-serif;
      background: #f7f8fb;
      color: #222;
      margin: 0;
      padding: 24px;
    }

    .container {
      max-width: 960px;
      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;
    }

    .dropzone {
      border: 2px dashed #7d8cff;
      border-radius: 16px;
      padding: 40px 20px;
      text-align: center;
      background: #f4f6ff;
      cursor: pointer;
      transition: 0.2s;
    }

    .dropzone.dragover {
      background: #e9edff;
      border-color: #5368ff;
    }

    .controls {
      margin-top: 20px;
      display: flex;
      gap: 20px;
      flex-wrap: wrap;
      align-items: center;
    }

    .preview-grid {
      margin-top: 24px;
      display: grid;
      grid-template-columns: 1fr 1fr;
      gap: 20px;
    }

    .panel {
      border: 1px solid #e5e7eb;
      border-radius: 12px;
      padding: 12px;
      background: #fafafa;
    }

    .panel img {
      width: 100%;
      border-radius: 8px;
      display: block;
    }

    button {
      border: 0;
      background: #4f46e5;
      color: #fff;
      padding: 12px 18px;
      border-radius: 10px;
      cursor: pointer;
      font-size: 1rem;
    }

    button:disabled {
      background: #9ca3af;
      cursor: not-allowed;
    }

    .note {
      margin-top: 16px;
      color: #555;
      font-size: 0.95rem;
    }

    .status {
      margin-top: 16px;
      font-weight: bold;
    }

    .download-link {
      display: inline-block;
      margin-top: 12px;
    }

    @media (max-width: 768px) {
      .preview-grid {
        grid-template-columns: 1fr;
      }
    }
  </style>
</head>
<body>
  <div class="container">
    <h1>個人情報保護モザイクアプリ</h1>
    <p>画像をドラッグ&ドロップし、モザイク対象を選んで処理します。</p>

    <div id="dropzone" class="dropzone">
      <p><strong>ここに画像をドラッグ&ドロップ</strong></p>
      <p>またはクリックして画像を選択</p>
      <input id="fileInput" type="file" accept="image/*" hidden>
    </div>

    <div class="controls">
      <label><input type="checkbox" name="targets" value="face" checked> 顔</label>
      <label><input type="checkbox" name="targets" value="plate"> ナンバープレート</label>
      <button id="processBtn" disabled>モザイク実行</button>
    </div>

    <p class="note">
      ナンバープレート検出器は <code>plate_detector.py</code> で差し替える構成です。
    </p>

    <div class="status" id="status"></div>

    <div class="preview-grid">
      <div class="panel">
        <h3>元画像</h3>
        <img id="originalPreview" alt="元画像プレビュー">
      </div>
      <div class="panel">
        <h3>処理後画像</h3>
        <img id="resultPreview" alt="処理後画像プレビュー">
        <a id="downloadLink" class="download-link" href="#" download="masked_output.jpg" hidden>処理後画像をダウンロード</a>
      </div>
    </div>
  </div>

  <script>
    const dropzone = document.getElementById("dropzone");
    const fileInput = document.getElementById("fileInput");
    const processBtn = document.getElementById("processBtn");
    const statusEl = document.getElementById("status");
    const originalPreview = document.getElementById("originalPreview");
    const resultPreview = document.getElementById("resultPreview");
    const downloadLink = document.getElementById("downloadLink");

    let selectedFile = null;

    function setStatus(text, isError = false) {
      statusEl.textContent = text;
      statusEl.style.color = isError ? "#dc2626" : "#111827";
    }

    function updatePreview(file) {
      const reader = new FileReader();
      reader.onload = e => {
        originalPreview.src = e.target.result;
      };
      reader.readAsDataURL(file);
      selectedFile = file;
      processBtn.disabled = false;
      setStatus("画像を読み込みました");
    }

    dropzone.addEventListener("click", () => fileInput.click());

    fileInput.addEventListener("change", (e) => {
      if (e.target.files.length > 0) {
        updatePreview(e.target.files[0]);
      }
    });

    dropzone.addEventListener("dragover", (e) => {
      e.preventDefault();
      dropzone.classList.add("dragover");
    });

    dropzone.addEventListener("dragleave", () => {
      dropzone.classList.remove("dragover");
    });

    dropzone.addEventListener("drop", (e) => {
      e.preventDefault();
      dropzone.classList.remove("dragover");
      if (e.dataTransfer.files.length > 0) {
        updatePreview(e.dataTransfer.files[0]);
      }
    });

    processBtn.addEventListener("click", async () => {
      if (!selectedFile) {
        setStatus("先に画像を選択してください", true);
        return;
      }

      const targets = [...document.querySelectorAll('input[name="targets"]:checked')]
        .map(el => el.value);

      if (targets.length === 0) {
        setStatus("モザイク対象を1つ以上選択してください", true);
        return;
      }

      const formData = new FormData();
      formData.append("image", selectedFile);
      for (const target of targets) {
        formData.append("targets", target);
      }

      setStatus("処理中です...");

      try {
        const response = await fetch("/process", {
          method: "POST",
          body: formData
        });

        if (!response.ok) {
          const data = await response.json();
          throw new Error(data.error || "処理に失敗しました");
        }

        const blob = await response.blob();
        const url = URL.createObjectURL(blob);

        resultPreview.src = url;
        downloadLink.href = url;
        downloadLink.hidden = false;
        setStatus("モザイク処理が完了しました");
      } catch (error) {
        setStatus(error.message, true);
      }
    });
  </script>
</body>
</html>


requirements.txt

Flask>=3.1
numpy>=2.0
opencv-python-headless>=4.10
```

使い方

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

bash

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

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

操作手順は以下です。

1. 画像をドラッグ&ドロップする
2. または ナンバープレート を選ぶ
3. 「モザイク実行」を押す
4. 処理後画像を確認してダウンロードする

個人情報保護モザイクアプリ上でモザイク処理を実行

今後の展望改善案

今回のPoCは静止画1枚の匿名化に絞っていますが、実運用を考えると改善余地はいくつかあります。

今回は顔とナンバープレートを中心にしましたが、名札、伝票、QRコード、PC画面、住所ラベルなども匿名化対象として追加すると、実用途により近づきます。

次に、モザイク方法も改善できます。モザイクだけでなく、ぼかし、黒塗り、塗りつぶし、透過マスクなどを切り替えられるようにしておくと、利用シーンに応じた出し分けがしやすくなります。OpenCV 側には平滑化系の基本機能がそろっているため、画像処理部分の拡張自体は比較的進めやすいです。 OpenCV Tutorial

さらに、現場運用を考えると、一括処理、監査ログ、保存期間の制御、認証の追加なども検討対象です。特に「画像を外へ出さずに処理したい」という要件がある場合は、ローカル完結の利点を活かしてアクセス制御まで含めて考えると、PoC から実務導入への見通しが立てやすくなります。

関連技術参考トピック

今回使用した、Flaskは軽量なWebアプリケーションフレームワークで、ファイルアップロードや小規模APIを始めやすいのが魅力です。 Flask Documentation flask.palletsprojects.com

画像加工の土台となる OpenCVは、ブラー、リサイズ、マスク処理など匿名化に必要な基本機能を広くカバーしています。顔検出を OpenCV ベースにすると、Raspberry Pi 上での導入や再現性の面でも扱いやすい構成にしやすいです。 OpenCV License  OpenCV Tutorial

また、OpenCVを活用した顔認識やAI処理への対応として、RenesasはRZシリーズを展開しています。今回の作例では顔認識やAI機能の実装までは行っていませんが、今後、顔認識やAIを活用したさまざまなアプリケーションへの展開を検討する際には、RZシリーズの活用も有効な選択肢になると感じました。
RZファミリ32ビットおよび64ビット・マイクロプロセッサ(MPU)

まとめ

今回は、Raspberry Pi 5 上で動く、Webアプリベースの個人情報保護アプリを作ってみました。

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

・ブラウザに画像をドラッグ&ドロップする
・顔やナンバープレートを対象として選ぶ
・自動でモザイクをかける
・ローカル環境で完結しやすい

という流れを通して、写真共有前の匿名化を少しラクにする 仕組みを小さく試せる題材になりました。

参考リンク

Raspberry Pi 5 公式ページ
Raspberry Pi Licensing
Flask: Uploading Files
Flask License
OpenCV License
OpenCV Image Smoothing / Filtering Tutorial
PaddleDetection
PP-Vehicle / Pipeline README
RZファミリ32ビットおよび64ビット・マイクロプロセッサ(MPU