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?

GPT-5で半自動モザイクアプリを作ってみた

Posted at

動機

私は自分や友人の顔が分かる画像をSNSに載せる際、必ず顔が分からないように投稿しています。
隠す方法は様々で、SNSに備えついている機能で隠したり、Photoshop等の画像編集アプリで加工したり...。
色々ありますが、どれも地味に手間...というか、考える時間が発生するんですよね。

脳死で画像UP ⇒ 自動で顔にモザイク ⇒ 加工後画像DL

このようなシンプルでかつ必要最低限、お金がかからなくて広告もないアプリがあったらいいな~ということで、自分で作ってみることにしました。

と言ってもアプリなんて作ったことないし...顔認識とかどうやるか分かんないし...どうしよ。
って状況でしたが、ふと「あれ?AI使えば作れちゃうんじゃね?」と思いつきました。

今回は自動モザイクアプリをAIを用いて作成した工程、結果を簡単にご紹介します。

※結果的に半自動になりました

目標

  1. 画像UP後、顔を認識して自動的にモザイクを掛ける
  2. 加工後画像を保存できる

全体構成

役割 主な技術
Backend (Flask) 画像を受け取り、顔を自動検出してモザイク処理し、結果画像を返す Flask, OpenCV, MediaPipe, NumPy
Frontend (React) ユーザーが画像をアップロードし、モザイク強度を調整したり手動で追加モザイクを描くUI React, HTML5 Canvas
通信 React → Flask に画像をPOST送信、処理済み画像を受け取る fetch API + Flask API(CORS対応)

GPTにこういうものが作りたいんだよね~と言ったら提案してくれました。

※さりげなくFrontend行に追加モザイクを手動で描くとか書いてある件には後ほど...

メインフォルダ構成

メインフォルダ構成
mosaic-project/
├─ backend/
│  ├─ app.py
│  └─ requirements.txt
│ 
└─ client/
   └─ src/
       └─ App.js
       └─ MosaicCanvas.jsx

編集した物のみ記載しています。

1. バックエンド(Flask)作成

必要パッケージの記載

今回のアプリを動かすために必要なPythonパッケージ(ライブラリ)一覧です。

Pythonパッケージ
flask
flask-cors
opencv-python
mediapipe
numpy

app.pyの記載

React(フロント)から送られた画像ファイルを受け取り、顔を検出してモザイクをかけ、加工済み画像を返す役割を担っています。

app.py
from flask import Flask, request, send_file, jsonify
from flask_cors import CORS
import cv2
import numpy as np
import mediapipe as mp
import tempfile
import os

app = Flask(__name__)
CORS(app)

mp_face_detection = mp.solutions.face_detection

def apply_mosaic(img, detections, strength=15):
    h, w, _ = img.shape
    for detection in detections:
        bbox = detection.location_data.relative_bounding_box
        x1 = int(bbox.xmin * w)
        y1 = int(bbox.ymin * h)
        x2 = int((bbox.xmin + bbox.width) * w)
        y2 = int((bbox.ymin + bbox.height) * h)

        # 画面外に出ないよう調整
        x1, y1 = max(0, x1), max(0, y1)
        x2, y2 = min(w, x2), min(h, y2)

        face = img[y1:y2, x1:x2]
        if face.size > 0:
            face = cv2.resize(face, (strength, strength))
            face = cv2.resize(face, (x2 - x1, y2 - y1), interpolation=cv2.INTER_NEAREST)
            img[y1:y2, x1:x2] = face
    return img

@app.route("/api/mosaic", methods=["POST"])
def mosaic():
    try:
        if "image" not in request.files:
            return jsonify({"error": "No image uploaded"}), 400

        file = request.files["image"]
        strength = int(request.form.get("strength", 15))  # モザイク強さ
        img = cv2.imdecode(np.frombuffer(file.read(), np.uint8), cv2.IMREAD_COLOR)

        all_detections = []

        rgb_img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)

        # Short range モデル (近距離複数人)
        with mp_face_detection.FaceDetection(model_selection=0, min_detection_confidence=0.45) as fd_short:
            res_short = fd_short.process(rgb_img)
            if res_short.detections:
                all_detections.extend(res_short.detections)

        # Full range モデル (遠距離向け)
        with mp_face_detection.FaceDetection(model_selection=1, min_detection_confidence=0.45) as fd_full:
            res_full = fd_full.process(rgb_img)
            if res_full.detections:
                all_detections.extend(res_full.detections)

        # モザイク処理
        if all_detections:
            img = apply_mosaic(img, all_detections, strength=strength)

        # 一時ファイルに保存して返却
        tmp_file = tempfile.NamedTemporaryFile(delete=False, suffix=".jpg")
        cv2.imwrite(tmp_file.name, img)
        tmp_file.close()

        return send_file(tmp_file.name, mimetype="image/jpeg")

    except Exception as e:
        return jsonify({"error": str(e)}), 500
    finally:
        # 処理後に一時ファイルを削除
        if "tmp_file" in locals():
            try:
                os.unlink(tmp_file.name)
            except Exception:
                pass

if __name__ == "__main__":
    app.run(debug=True)

Flaskサーバー起動

必要パッケージとapp.pyの記載ができたので、実際に起動します。

  1. cd backend
  2. pip install -r requirements.txt ← こちらで記載した必要パッケージのインストール
  3. python app.py ← Flask起動

起動後、この表示が出ていればFlaskサーバーの起動が出来ています。

image.png

2. フロントエンド(React)作成

事前準備

  1. Node.jsのインストール
  2. npx create-react-app client ← Reactプロジェクトを自動生成
  3. npm install ← 不足してるようであればパッケージインストール

App.jsの記載

ユーザーがUPした画像を取得し、POSTでFlaskサーバーへの画像送信、Flaskから返ってきた加工済み画像の表示とダウンロードをさせる役割を担っています。

App.js
import React, { useState } from "react";

export default function App() {
    const [preview, setPreview] = useState(null);
    const [resultUrl, setResultUrl] = useState(null);
    const [uploading, setUploading] = useState(false);
    const [strength, setStrength] = useState(15);

    const handleFileChange = async (e) => {
        const file = e.target.files[0];
        if (!file) return;
        setPreview(URL.createObjectURL(file));
        setResultUrl(null);

        // 自動モザイク適用(バックエンド呼び出し)
        const fd = new FormData();
        fd.append("image", file);
        fd.append("strength", strength);
        setUploading(true);
        try {
            const res = await fetch("http://localhost:5000/api/mosaic", {
                method: "POST",
                body: fd,
            });
            if (!res.ok) {
                const err = await res.json().catch(() => ({}));
                throw new Error(err.error || res.statusText);
            }
            const blob = await res.blob();
            const url = URL.createObjectURL(blob);
            setResultUrl(url);
        } catch (err) {
            alert("失敗: " + err.message);
        } finally {
            setUploading(false);
        }
    };

    const handleStrengthChange = (e) => setStrength(Number(e.target.value));

    const handleDownload = () => {
        if (!resultUrl) return;
        const a = document.createElement("a");
        a.href = resultUrl;
        a.download = "mosaic.jpg";
        a.click();
    };

    return (
        <div style={{ maxWidth: 560, margin: "auto", padding: 16, textAlign: "center" }}>
            <h1>顔モザイクアプリ</h1>

            <input
                type="file"
                accept="image/*"
                onChange={handleFileChange}
                style={{ display: "block", margin: "12px auto" }}
                disabled={uploading}
            />

            <label>
                モザイク強さ: {strength}
                <input
                    type="range"
                    min="1"
                    max="60"
                    value={strength}
                    onChange={handleStrengthChange}
                    style={{ width: "100%" }}
                />
            </label>

            {uploading && <p>処理中です...</p>}

            {resultUrl && (
                <div style={{ marginTop: 18 }}>
                    <p>加工後</p>
                    <img src={resultUrl} alt="モザイク結果" style={{ maxWidth: "100%" }} />
                    <button
                        style={{ marginTop: 8, padding: "6px 12px" }}
                        onClick={handleDownload}
                    >
                        ダウンロード
                    </button>
                </div>
            )}
        </div>
    );
}

Reactサーバー(Node.js)起動

  1. cd client
  2. npm start ← React起動

起動後、cmdに下記表示があれば、正常に起動できています!
mosaic (3).jpg

3. 表示・動作確認

表示確認

先程のReact起動と同時に、http://localhost:3000 でページが自動で開きました。
ページデザインに関してはGPTに作ってもらったままなので鬼ダサいです。
image.png
表示は問題なさそうですね。

動作確認

次は実際に画像をUPして、顔を認識して自動でモザイクがかかり、DLできるか確認します。
加工する画像はこちらです。
33200724_s.jpg

実際の動き↓
Gif1.gif
自動で顔にモザイクを掛けられていることが分かります。

DLした加工画像↓
mosaic.jpg

これで完成!と言いたいところですが...

別画像を試してみる

今度はこちらの画像で試してみます。
32868462_s.jpg

同じように画像UPとDLした結果↓
mosaic (6).jpg

奥の3人の顔は認識できモザイクが適用されていますが、手前の2人は顔認識ができていません。

どうやら今回顔認識の役割を担っているMediapipeライブラリは、真横顔や顔付近に手などの障害物がある場合、顔の特徴が不明瞭として顔認識ができないようです:cry:

どうする

始めに脳死で画像UPして自動的に~とか言ってたのに誠に遺憾ですが...。
手動でドラッグして選択した箇所に、モザイクをかけれる機能を追加することにしました。

4. (再)フロントエンド作成

App.js変更点

変更内容 目的 変更箇所
MosaicCanvas コンポーネントを導入 手動モザイクを描けるように&最後に追加したモザイクを消す import MosaicCanvas from "./MosaicCanvas";<MosaicCanvas ... />
useRef の追加 Canvas を外から操作できるように(例:ダウンロード) const canvasRef = useRef(null);
handleDownload 関数で Canvas の内容を保存 手動で描いたモザイクを含めてダウンロード const handleDownload = () => {...} の部分
④ 「ひとつ前に戻す」機能を追加 最後に描いたモザイクを取り消せるようにする MosaicCanvas.jsundoLast() 実装済み → App.js に「ひとつ前に戻す」ボタンを追加

MosaicCanvasコンポーネントの追加

加工後画像上でマウスドラッグを行い、モザイクを描画できるようになるためにMosaicCanvasというコンポーネントを新規で追加します。
コンポーネント内で行ってることは:

  • imageUrlで渡された画像をキャンバスに描画
  • マウス操作で指定した範囲にモザイクを適用
  • 最後に手動追加したモザイクを戻す
  • ref経由で親コンポーネント(App.js)からアクセス可能に
MosaicCanvas.jsx
import React, { useState, useRef, useEffect, forwardRef, useImperativeHandle } from "react";

const MosaicCanvas = forwardRef(({ imageUrl, strength = 15 }, ref) => {
    const canvasRef = useRef(null);
    const [dragging, setDragging] = useState(false);
    const [start, setStart] = useState(null);
    const [current, setCurrent] = useState(null);
    const [img, setImg] = useState(null);
    const [mosaicStrength, setMosaicStrength] = useState(strength);
    const appliedAreas = useRef([]); // 過去のモザイク情報

    // 親コンポーネントから操作可能に
    useImperativeHandle(ref, () => ({
        undoLast: () => {
            if (appliedAreas.current.length > 0) {
                appliedAreas.current.pop();
                redrawCanvas();
            }
        },
        getCanvas: () => canvasRef.current // ← ここで canvas を取得
    }));

    // 画像ロード
    useEffect(() => {
        if (!imageUrl) return;
        const image = new Image();
        image.src = imageUrl;
        image.onload = () => {
            setImg(image);
            const canvas = canvasRef.current;
            canvas.width = image.width;
            canvas.height = image.height;
            redrawCanvas(image);
        };
    }, [imageUrl]);

    // モザイク強さ変更時
    useEffect(() => {
        setMosaicStrength(strength);
        redrawCanvas();
    }, [strength]);

    const getMousePos = (e) => {
        const canvas = canvasRef.current;
        const rect = canvas.getBoundingClientRect();
        const scaleX = canvas.width / rect.width;
        const scaleY = canvas.height / rect.height;
        return {
            x: (e.clientX - rect.left) * scaleX,
            y: (e.clientY - rect.top) * scaleY
        };
    };

    const applyMosaic = (x, y, w, h, strength) => {
        if (!img) return;
        const ctx = canvasRef.current.getContext("2d");
        const imageData = ctx.getImageData(x, y, w, h);

        const tempCanvas = document.createElement("canvas");
        tempCanvas.width = w;
        tempCanvas.height = h;
        tempCanvas.getContext("2d").putImageData(imageData, 0, 0);

        const small = document.createElement("canvas");
        small.width = Math.max(1, Math.floor(w / strength));
        small.height = Math.max(1, Math.floor(h / strength));
        small.getContext("2d").drawImage(tempCanvas, 0, 0, small.width, small.height);

        ctx.imageSmoothingEnabled = false;
        ctx.drawImage(small, x, y, w, h);
    };

    const redrawCanvas = (imageOverride) => {
        if (!img && !imageOverride) return;
        const canvas = canvasRef.current;
        const ctx = canvas.getContext("2d");
        ctx.clearRect(0, 0, canvas.width, canvas.height);
        ctx.drawImage(imageOverride || img, 0, 0);
        appliedAreas.current.forEach(a => applyMosaic(a.x, a.y, a.w, a.h, a.strength));
    };

    const handleMouseDown = (e) => {
        setDragging(true);
        setStart(getMousePos(e));
    };

    const handleMouseMove = (e) => {
        if (!dragging || !start) return;
        const pos = getMousePos(e);
        setCurrent(pos);
        redrawCanvas();
        const x = Math.min(start.x, pos.x);
        const y = Math.min(start.y, pos.y);
        const w = Math.abs(start.x - pos.x);
        const h = Math.abs(start.y - pos.y);
        if (w > 0 && h > 0) {
            const ctx = canvasRef.current.getContext("2d");
            ctx.fillStyle = "rgba(0,0,0,0.3)";
            ctx.fillRect(x, y, w, h);
        }
    };

    const handleMouseUp = (e) => {
        if (!dragging || !start) return;
        setDragging(false);
        const end = getMousePos(e);
        const x = Math.min(start.x, end.x);
        const y = Math.min(start.y, end.y);
        const w = Math.abs(start.x - end.x);
        const h = Math.abs(start.y - end.y);
        if (w < 5 || h < 5) {
            setStart(null);
            setCurrent(null);
            redrawCanvas();
            return;
        }
        appliedAreas.current.push({ x, y, w, h, strength: mosaicStrength });
        applyMosaic(x, y, w, h, mosaicStrength);
        setStart(null);
        setCurrent(null);
        redrawCanvas();
    };

    return (
        <canvas
            ref={canvasRef}
            style={{ maxWidth: "100%", border: "1px solid #ccc", cursor: "crosshair" }}
            onMouseDown={handleMouseDown}
            onMouseMove={handleMouseMove}
            onMouseUp={handleMouseUp}
        />
    );
});

export default MosaicCanvas;

変更後App.js

上記MosaicCanvasに合わせて変更を加えたApp.jsです。

App.js
import React, { useState, useRef } from "react";
import MosaicCanvas from "./MosaicCanvas";

export default function App() {
    const [preview, setPreview] = useState(null);
    const [resultUrl, setResultUrl] = useState(null);
    const [uploading, setUploading] = useState(false);
    const [strength, setStrength] = useState(15);

    const canvasRef = useRef(null);

    const handleFileChange = async (e) => {
        const file = e.target.files[0];
        if (!file) return;
        setPreview(URL.createObjectURL(file));
        setResultUrl(null);

        // 自動モザイク適用(バックエンド呼び出し)
        const fd = new FormData();
        fd.append("image", file);
        fd.append("strength", strength);
        setUploading(true);
        try {
            const res = await fetch("http://localhost:5000/api/mosaic", {
                method: "POST",
                body: fd,
            });
            if (!res.ok) {
                const err = await res.json().catch(() => ({}));
                throw new Error(err.error || res.statusText);
            }
            const blob = await res.blob();
            const url = URL.createObjectURL(blob);
            setResultUrl(url);
        } catch (err) {
            alert("失敗: " + err.message);
        } finally {
            setUploading(false);
        }
    };

    const handleStrengthChange = (e) => setStrength(Number(e.target.value));

    const handleDownload = () => {
        const canvas = canvasRef.current?.getCanvas();
        if (!canvas) return;
        const url = canvas.toDataURL("image/jpeg");
        const a = document.createElement("a");
        a.href = url;
        a.download = "mosaic.jpg";
        a.click();
    };

    return (
        <div style={{ maxWidth: 560, margin: "auto", padding: 16, textAlign: "center" }}>
            <h1>顔モザイクアプリ</h1>

            <input
                type="file"
                accept="image/*"
                onChange={handleFileChange}
                style={{ display: "block", margin: "12px auto" }}
                disabled={uploading}
            />

            <label>
                モザイク強さ: {strength}
                <input
                    type="range"
                    min="1"
                    max="60"
                    value={strength}
                    onChange={handleStrengthChange}
                    style={{ width: "100%" }}
                />
            </label>

            {uploading && <p>処理中です...</p>}

            {resultUrl && (
                <div style={{ marginTop: 18 }}>
                    <p>加工後</p>
                    <MosaicCanvas ref={canvasRef} imageUrl={resultUrl} strength={strength} />
                    <button onClick={() => canvasRef.current?.undoLast()} style={{ marginLeft: 8 }} > ひとつ前に戻す </button>
                    <button
                        style={{ marginTop: 8, padding: "6px 12px" }}
                        onClick={handleDownload}
                    >
                        加工後をダウンロード
                    </button>
                </div>
            )}
        </div>
    );
}

5. 再び動作確認

image.png

既存機能は問題なく動いてます。
では、画像上でドラックしてモザイクがかけられ、戻せるか確認します。
Gif2.gif

問題なさそうですね。
手動モザイクをかけてDLした画像↓
mosaic (7).jpg

DLしても手動追加分が適応されてます。

まとめ

今回はGPTの出力をもとに、半自動モザイクアプリを作成してみました。
とてつもなくシンプルな動作ですが、ほぼGPTに聞くだけでアプリが作れてしまいました。
私は日常的にPythonを使用していないので、Flask部分を作成してる最中は「❓」ばかり浮かんでいました:joy_cat:
そんな未経験の人間でもアプリが作成できる時代に感動しつつも、AIが書いたコードを100%信用するのはリスクの匂いがしました。
業務などで今回のようなバイブコーディングをする場合は、最終的にAIが書いたコードを人間が細かくレビューする必要があると感じました。

使い方に気をつけてAIを活用していこうと改めて思うことができた制作でした!


※本記事内の画像は全てphotoACの物を使用しています。

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?