2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Gemini API × Gradioで作る!カレー具材検出アプリ

Last updated at Posted at 2025-09-09

はじめに

curry-detection-diagram.png

Google の Gemini API を使って、カレー画像から具材を自動検出するWebアプリケーションを作成しました。この記事では、Gemini 2.5の構造化出力機能と、GradioによるUIの実装について解説します。

なぜカレーの具材検出?

カレーは日本の国民食とも言える料理ですが、家庭やお店によって具材のバリエーションは実に豊富です。じゃがいも、にんじん、玉ねぎの定番具材から、地域特有の具材まで、その組み合わせは無限大。そんなカレーの具材を画像から自動検出できれば、レシピ分析や食事記録など、様々な応用が可能になります。

他の人が取り組んでいるのを見て、自分も挑戦してみたいと思ったことが主なモチベーションになっています。

完成イメージ

FireShot Capture 010 - Gemini × Gradio:カレー具材オブジェクト検出 - [a9be84e9fc87cd529a.gradio.live].png

システムの特徴

1. 構造化出力による確実なJSON取得

Gemini APIのresponse_schemaパラメータを活用することで、必ず指定したJSON形式で結果を取得できます。これにより、後処理でのパースエラーを防ぎ、安定したシステムを構築できました。

RESPONSE_SCHEMA = {
    "type": "array",
    "items": {
        "type": "object",
        "properties": {
            "label": {"type": "string"},
            "confidence": {"type": "number"},
            "box_2d": {"type": "array", "items": {"type": "number"}}
        }
    }
}

2. バウンディングボックスによる位置特定

単に「肉がある」「じゃがいもがある」という検出だけでなく、画像内のどこにあるかを矩形(バウンディングボックス)で特定します。座標は0-1000の正規化された値で返され、画像サイズに応じて実座標に変換されます。

3. 信頼度スコアによるフィルタリング

各検出結果には0.0〜1.0の信頼度スコアが付与され、UIのスライダーで閾値を調整可能です。これにより、誤検出を減らしつつ、用途に応じた精度調整が可能になります。

技術スタック

Google の最新マルチモーダル AI である Gemini API(gemini-2.5-flash 推奨)を利用し、Gradio を使って対話的な Python ベースの UI を構築します。画像処理やアノテーションの描画には PIL(Pillow)を用います。

アーキテクチャの工夫点

プロンプトエンジニアリング

検出対象を明確に定義し、JSONのみを返すよう指示することで、安定した出力を実現:

  • 対象具材を明示的にリスト化(じゃがいも、にんじん、玉ねぎ、肉類、ご飯、ルー、福神漬け)
  • スプーンや皿などの非具材要素を除外するよう指示
  • 重複検出の処理ルールを明確化

EXIF対応

スマートフォンで撮影した画像は、EXIF情報により回転している場合があります。ImageOps.exif_transpose()で自動補正し、正しい向きで処理します。

バッチ処理の実装

複数画像を一括処理する機能により、大量のカレー画像を効率的に解析可能。結果はJSONL形式でダウンロードでき、後続の分析に活用できます。

ユースケース

本システムは複数のカレー画像を解析し、具材の出現頻度から人気の組み合わせやトレンドを把握できます。さらに、検出結果を栄養管理アプリに活用すれば、栄養価の推定や食事記録の自動化が可能です。

たたし、ルーに完全に覆われたり、具材が重なっていたりすると、具材の検出は難しいという制限があります。

今後の改善ポイント

ラベルを日本語出力にすると文字化けが発生します。そのため、英語で出力しています。マッチする日本語フォントをインストール、実装すれば解決する見込みです(未検証)。

まとめ

image.png

Gemini APIの構造化出力機能を活用することで、従来は専用の物体検出モデルが必要だったタスクを、プロンプトエンジニアリングのみで実現できました。特にカレーのような複雑な料理画像でも、適切なプロンプト設計により実用的な精度を達成できることが実証されました。

この手法は、カレーに限らず様々な料理や物体の検出に応用可能です。ぜひ皆さんも、身近な画像認識タスクにGemini APIを活用してみてください。

実装

# ================== 0) インストール(Colab) ==================
!pip -q install google-genai gradio Pillow matplotlib

# ================== 1) import / 設定 ==========================
from google import genai
from google.genai import types
from PIL import Image, ImageDraw, ImageOps
import gradio as gr
import json, os, io, tempfile, pathlib, textwrap

# Colab の userdata から API キーを拾える場合は利用(未設定なら無視)
try:
    from google.colab import userdata
    os.environ.setdefault("GOOGLE_API_KEY", userdata.get("GEMINI_API_KEY") or "")
except Exception:
    pass

DEFAULT_MODEL = "gemini-2.5-flash"

# 具材検出プロンプト(必要に応じてUIで上書き可能)
DEFAULT_PROMPT = textwrap.dedent("""
  あなたはカレー画像の具材検出器です。以下の方針で検出し、JSONのみを返してください。
  - 対象: じゃがいも/にんじん/玉ねぎ/肉(牛/豚/鶏のいずれか分かれば)/ご飯/カレーのルー/福神漬け/その他(不明は "other")
  - スプーン・皿・背景は基本的に無視
  - "label" は英語で出力する
  - "confidence" は 0.0〜1.0。指定しきい値未満は出力しない
  - "box_2d" は [ymin, xmin, ymax, xmax](0〜1000 正規化)
  - 重複は同一ラベルで大きく重なる場合、confidence が高い方のみ残す
  返答は必ずスキーマ準拠の JSON 配列のみ。
""").strip()

# 構造化出力スキーマ(JSON)
RESPONSE_SCHEMA = {
    "type": "array",
    "items": {
        "type": "object",
        "properties": {
            "label": {"type": "string"},
            "confidence": {"type": "number"},
            "box_2d": {
                "type": "array",
                "items": {"type": "number"},
                "minItems": 4,
                "maxItems": 4,
                "description": "[ymin, xmin, ymax, xmax] normalized to 0–1000"
            }
        },
        "required": ["label", "confidence", "box_2d"]
    }
}

# ================== 2) 推論ユーティリティ ======================
def _mk_client(api_key: str | None):
    key = (api_key or "").strip() or os.environ.get("GOOGLE_API_KEY", "").strip()
    if not key:
        raise RuntimeError("Google API Key が未設定です。UIのAPI Key欄または環境変数(GOOGLE_API_KEY)で設定してください。")
    return genai.Client(api_key=key)

def _generate(
    client: genai.Client,
    model_name: str,
    image: Image.Image,
    prompt_text: str,
    threshold: float
):
    # EXIF回転を補正
    image = ImageOps.exif_transpose(image.convert("RGB"))
    W, H = image.size

    # スキーマ固定の JSON モード
    config = types.GenerateContentConfig(
        response_mime_type="application/json",
        response_schema=RESPONSE_SCHEMA,
        temperature=0.2,
    )
    # しきい値はプロンプトにも反映
    prompt = (prompt_text or DEFAULT_PROMPT).replace("指定しきい値", f"{threshold:.2f}")

    resp = client.models.generate_content(
        model=model_name,
        contents=[image, prompt],
        config=config
    )

    # JSON を安全に解釈
    try:
        detections = json.loads(resp.text)
    except Exception as e:
        raise RuntimeError(f"JSON の解釈に失敗: {e}")

    # クライアント側でもしきい値フィルタ & 絶対座標へ変換
    out = []
    for d in detections if isinstance(detections, list) else []:
        try:
            conf = float(d.get("confidence", 0.0))
            if conf < threshold:
                continue
            ymin, xmin, ymax, xmax = d["box_2d"]
            out.append({
                "label": str(d.get("label", "other")),
                "confidence": conf,
                "box_2d": [float(ymin), float(xmin), float(ymax), float(xmax)],
                "bbox_xyxy": [
                    int(xmin/1000 * W),
                    int(ymin/1000 * H),
                    int(xmax/1000 * W),
                    int(ymax/1000 * H),
                ],
            })
        except Exception:
            # 壊れた要素はスキップ
            continue
    return image, out

def _draw(image: Image.Image, detections: list[dict]):
    img = image.copy()
    draw = ImageDraw.Draw(img)
    for det in detections:
        x1, y1, x2, y2 = det["bbox_xyxy"]
        draw.rectangle([x1, y1, x2, y2], outline=(255, 0, 0), width=3)
        draw.text((x1, max(0, y1 - 14)), f'{det["label"]} {det["confidence"]:.2f}', fill=(255, 0, 0))
    return img

# =============== 3) Gradio ハンドラ(単体画像) ==================
def gr_single(image, model_name, threshold, prompt_text, api_key):
    try:
        client = _mk_client(api_key)
        base_img, detections = _generate(client, model_name, image, prompt_text, threshold)
        drawn = _draw(base_img, detections)
        return drawn, detections, gr.update(visible=False), ""  # download用は非表示
    except Exception as e:
        return None, None, gr.update(visible=False), f"エラー: {e}"

# =============== 4) Gradio ハンドラ(バッチ) ====================
def gr_batch(files, model_name, threshold, prompt_text, api_key):
    try:
        client = _mk_client(api_key)
        gallery = []
        all_results = []
        tmpdir = tempfile.mkdtemp(prefix="curry_det_")
        jsonl_path = os.path.join(tmpdir, "results.jsonl")

        with open(jsonl_path, "w", encoding="utf-8") as fjl:
            for f in (files or []):
                try:
                    # gr.File -> f は tempfile もしくは str パス
                    path = getattr(f, "name", None) or f
                    img = Image.open(path).convert("RGB")

                    base_img, detections = _generate(client, model_name, img, prompt_text, threshold)
                    drawn = _draw(base_img, detections)

                    # 保存
                    stem = pathlib.Path(path).stem
                    out_img = os.path.join(tmpdir, f"{stem}_annotated.jpg")
                    out_json = os.path.join(tmpdir, f"{stem}.json")
                    drawn.save(out_img, quality=95)
                    with open(out_json, "w", encoding="utf-8") as fj:
                        json.dump(detections, fj, ensure_ascii=False, indent=2)

                    # 画面用
                    gallery.append(drawn)
                    record = {
                        "input": os.path.basename(path),
                        "detections": detections,
                        "annotated": out_img,
                        "json": out_json
                    }
                    all_results.append(record)
                    fjl.write(json.dumps(record, ensure_ascii=False) + "\n")
                except Exception as ie:
                    all_results.append({"input": str(f), "error": str(ie)})

        return gallery, all_results, gr.update(visible=True, value=jsonl_path), ""
    except Exception as e:
        return None, None, gr.update(visible=False), f"エラー: {e}"

# ================== 5) UI 構築 ================================
with gr.Blocks(title="Gemini × Gradio:カレー具材オブジェクト検出", theme="soft") as demo:
    gr.Markdown("## 🍛 Gemini × Gradio:カレー具材オブジェクト検出デモ")

    with gr.Row():
        model = gr.Dropdown(
            choices=["gemini-2.5-flash", "gemini-1.5-pro", "gemini-1.5-flash"],
            value=DEFAULT_MODEL, label="モデル"
        )
        thr = gr.Slider(0.0, 1.0, value=0.30, step=0.05, label="信頼度しきい値")
        api_key = gr.Textbox(label="Google API Key(空なら環境変数を利用)", type="password", placeholder="AI Studio / Google Cloud で取得")

    prompt_tb = gr.Textbox(label="プロンプト(空ならデフォルト)", value=DEFAULT_PROMPT, lines=8)

    with gr.Tab("単体画像"):
        in_img = gr.Image(type="pil", label="画像をアップロード")
        btn_single = gr.Button("検出する", variant="primary")
        out_img = gr.Image(label="アノテーション結果")
        out_json = gr.JSON(label="検出JSON")
        out_file = gr.File(label="(単体はJSONL出力なし)", visible=False)
        out_err = gr.Markdown()

        btn_single.click(
            gr_single,
            inputs=[in_img, model, thr, prompt_tb, api_key],
            outputs=[out_img, out_json, out_file, out_err]
        )

    with gr.Tab("バッチ(複数画像)"):
        in_files = gr.File(label="画像を複数選択", file_count="multiple", file_types=["image"])
        btn_batch = gr.Button("一括検出する", variant="secondary")
        # ★ Gradio 4系では .style() は不可。columns 等はコンストラクタ引数で指定
        out_gallery = gr.Gallery(
            label="アノテーション結果ギャラリー",
            columns=2,
            object_fit="contain",
            height=600
        )
        out_all_json = gr.JSON(label="結果まとめ(ファイル別)")
        out_jsonl = gr.File(label="ダウンロード: results.jsonl(全件)", visible=False)
        out_err2 = gr.Markdown()

        btn_batch.click(
            gr_batch,
            inputs=[in_files, model, thr, prompt_tb, api_key],
            outputs=[out_gallery, out_all_json, out_jsonl, out_err2]
        )

demo.launch(share=True)  # Colab外部共有を有効化

Google Colab 上で動作確認を行いました。本実装は外部共有を前提としたデモ用のものなので、利用環境に応じてセキュリティ面に十分配慮する必要があります。特に API キーの管理や取り扱いには注意してください。

参考リンク

2
1
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
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?