VLMで防災袋の中身をチェック

背景や目的


防災袋の中身を画像でチェックするツールを検討しています。

実際のサービスでは、精度だけでなく、処理速度や利用料金なども重要なので、VLMの高性能版「GPT-5.6 Sol」と低コスト版「GPT-5.4 mini」の両方で試し、精度と費用面を検証します。

画像処理系AIの導入を検討する際の参考になれば幸いです。

試作品の仕様


防災袋の画像をVLMで推論させ、不足品を指摘するWebアプリを試作します。

アプリ実行環境:AWS Lambda
利用環境:スマートフォンのブラウザから関数URLへアクセス
VLM:OpenAI GPT-5.6 Sol、GPT-5.4 mini

アプリの画面は、以下のようにします。

アプリ作成に進みます。

試作品作成の手順


以下の手順で進めます。

1.アプリ作成:実行環境作成
2.アプリ作成:コード作成
3.実験
4.考察

1.アプリ作成:実行環境作成


アプリはAWS Lambda関数上で実行する構成にします。

AWS上にLambda関数を以下の手順で作成します。
・AWSコンソールでLambdaメニューに遷移し、トップ画面で「関数の作成」を押します。
   (※AWSアカウントの作成など基本設定はここでは割愛します)

・ 画面「関数の作成」では、以下の設定値で、作成します。

設定項目
関数名bosaibukuro-check ※お好きな名称で
ランタイムpython3.14
アーキテクチャx86_64(デフォルト)
実行ロール新規ロール(デフォルト)
関数URL を有効化チェック
認証タイプNONE

Lambda関数が作成され、以下の画面が表示されればOKです。

これで、Lambda関数の作成は完了です。
次に、アプリのコードを作成してデプロイします。

2.アプリ作成:コード作成


アプリのコードを準備します。

AWS Lambdaで動作させるので、Lambda用の以下2ファイルにしておきます。
・lambda_function.py (処理部)
・index.html (表示部)

まず、処理部のlambda_function.pyを作成します。

コードでは、選択したAIモデルを指定して、防災袋の画像をOpenAI APIへ送信します。
VLMがチェックリストを基準に不足品を判定し、結果を画面へ返します。

実際のコードは、以下の通りです。

lambda_function.py
import json
import os
import urllib.request
import urllib.error

OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY")
OPENAI_API_BASE = os.environ.get("OPENAI_API_BASE", "https://api.openai.com/v1")

# 実際に使えるモデルIDに置き換える
MODEL_MAP = {
    "gpt-5.6": "gpt-5.6",
    "gpt-5.4-mini": "gpt-5.4-mini",
}

# チェックリスト(出典: 首相官邸「災害の備えチェックリスト」非常用持ち出し袋)
# https://www.kantei.go.jp/jp/content/000182715.pdf
CHECKLIST = [
    "水",
    "食品(ご飯・レトルト食品・ビスケット・チョコ・乾パンなど、最低3日分)",
    "防災用ヘルメット・防災ずきん",
    "衣類・下着",
    "レインウェア",
    "紐なしのズック靴",
    "懐中電灯",
    "携帯ラジオ",
    "予備電池・携帯充電器",
    "マッチ・ろうそく",
    "救急用品(ばんそうこう・包帯・消毒液・常備薬など)",
    "使い捨てカイロ",
    "ブランケット",
    "軍手",
    "洗面用具",
    "歯ブラシ・歯磨き粉",
    "タオル",
    "ペン・ノート",
    "マスク",
    "手指消毒用アルコール",
    "石けん・ハンドソープ",
    "ウェットティッシュ",
    "体温計",
    "貴重品(通帳・現金・パスポート・運転免許証・診察券・マイナンバーカードなど)",
]

_HTML_CACHE = None


def _load_html():
    global _HTML_CACHE
    if _HTML_CACHE is None:
        html_path = os.path.join(os.path.dirname(__file__), "index.html")
        with open(html_path, "r", encoding="utf-8") as f:
            _HTML_CACHE = f.read()
    return _HTML_CACHE


def _response(status, body, content_type="application/json"):
    if content_type == "application/json" and not isinstance(body, str):
        body = json.dumps(body, ensure_ascii=False)
    return {
        "statusCode": status,
        "headers": {"Content-Type": content_type},
        "body": body,
        "isBase64Encoded": False,
    }


def _build_prompt():
    items_desc = "\n".join(f"- {item}" for item in CHECKLIST)
    total = len(CHECKLIST)
    return (
        "あなたは防災袋(非常用持ち出し袋)の中身をチェックするアシスタントです。"
        f"次のチェックリスト(全{total}項目)をもとに、画像に写っている中身を評価してください。\n\n"
        f"{items_desc}\n\n"
        "判定の際は、項目名と完全に一致しなくても、同等の役割を果たす類似品があれば"
        "揃っているとみなしてください(例:懐中電灯の代わりのランタンやスマホのライト、"
        "マッチの代わりのライターなど)。"
        "また、箱やパッケージに入っていて中身が直接見えない場合でも、"
        "箱の印字・ラベルの文字から中身が判別できればそれも揃っているとみなしてください。\n\n"
        "回答は次の構成の簡潔な日本語の文章にしてください(JSONは使わない)。\n"
        "1. 簡潔な一言のリード文(自由)\n"
        f"2. 次の文を、実際に数えた不足項目数をXに入れて必ずそのまま含める:"
        f"「災害の『備え』チェックリストと比較すると、{total}アイテム中X個が不足しているようです。"
        "詳細は以下のとおりです。」\n"
        "3. 不足している項目の詳細(簡潔な自由文)\n"
        f"4. 次の文を、Yに{total}から不足項目数を引いた数を入れて必ずそのまま含める:"
        "「他のY個アイテムは準備OKでした。」"
    )


def _call_openai(model_label, image_data_url):
    if not OPENAI_API_KEY:
        raise RuntimeError("OPENAI_API_KEY is not configured on the Lambda")

    model_id = MODEL_MAP.get(model_label)
    if not model_id:
        raise ValueError(f"Unknown model: {model_label}")

    payload = {
        "model": model_id,
        "instructions": _build_prompt(),
        "input": [
            {
                "role": "user",
                "content": [
                    {"type": "input_image", "image_url": image_data_url},
                ],
            },
        ],
    }

    req = urllib.request.Request(
        f"{OPENAI_API_BASE}/responses",
        data=json.dumps(payload).encode("utf-8"),
        headers={
            "Content-Type": "application/json",
            "Authorization": f"Bearer {OPENAI_API_KEY}",
        },
        method="POST",
    )

    try:
        with urllib.request.urlopen(req, timeout=80) as resp:
            data = json.loads(resp.read().decode("utf-8"))
    except urllib.error.HTTPError as e:
        detail = e.read().decode("utf-8", errors="replace")
        raise RuntimeError(f"OpenAI API error {e.code}: {detail}") from e

    for item in data.get("output", []):
        if item.get("type") == "message":
            for part in item.get("content", []):
                if part.get("type") == "output_text":
                    return part.get("text", "")
    raise RuntimeError(f"Unexpected response from OpenAI: {data}")


def lambda_handler(event, context):
    request_context = event.get("requestContext", {})
    http = request_context.get("http", {})
    method = http.get("method", "GET")
    path = event.get("rawPath", "/")

    if method == "GET" and (path == "/" or path == "/index.html"):
        return _response(200, _load_html(), content_type="text/html; charset=utf-8")

    if method == "POST" and path == "/analyze":
        try:
            body = event.get("body", "{}") or "{}"
            payload = json.loads(body)
            image_data_url = payload.get("image")
            model_label = payload.get("model")

            if not image_data_url:
                return _response(400, {"error": "image is required"})
            if model_label not in MODEL_MAP:
                return _response(400, {"error": f"invalid model: {model_label}"})

            text = _call_openai(model_label, image_data_url)
            return _response(200, {"text": text})
        except Exception as e:
            return _response(500, {"error": str(e)})

    return _response(404, {"error": "not found"})

判定基準には、首相官邸が公開している「災害の『備え』チェックリスト」を使用します。
PDF(https://www.kantei.go.jp/jp/content/000182715.pdf)

チェックリストはコード内(17行目)に定義し、VLMの判定結果と比較します。

次に、画面表示部用のHTMLファイル index.htmlを作成します。

index.html
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>防災袋 中身チェック</title>
<style>
  :root {
    --bg: #e8f5ea;
    --card: #ffffff;
    --accent: #f5b301;
    --accent-dark: #d99a00;
    --text: #2b2622;
    --muted: #7a7268;
    --ok: #2f8f4e;
    --ng: #c0392b;
  }
  * { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
  [hidden] { display: none !important; }
  html, body {
    margin: 0;
    height: 100%;
    background: var(--bg);
    color: var(--text);
    font-family: -apple-system, BlinkMacSystemFont, "Hiragino Sans", "Yu Gothic", sans-serif;
  }
  #app {
    max-width: 480px;
    margin: 0 auto;
    min-height: 100dvh;
    display: flex;
    flex-direction: column;
  }
  .screen { display: none; flex-direction: column; flex: 1; padding: 20px 20px calc(24px + env(safe-area-inset-bottom)); }
  .screen.active { display: flex; }

  h1 { font-size: 22px; margin: 12px 0 4px; }
  h2 { font-size: 15px; margin: 24px 0 10px; color: var(--muted); }
  p.sub { color: var(--muted); margin: 0 0 8px; font-size: 14px; }

  .model-option {
    display: flex;
    align-items: center;
    gap: 12px;
    background: var(--card);
    border: 2px solid transparent;
    border-radius: 14px;
    padding: 16px;
    margin-bottom: 12px;
    box-shadow: 0 1px 3px rgba(0,0,0,0.08);
  }
  .model-option.selected { border-color: var(--accent); }
  .model-option input { width: 20px; height: 20px; accent-color: var(--accent); }
  .model-option .label { font-size: 16px; font-weight: 600; }

  button {
    font: inherit;
    border: none;
    border-radius: 14px;
    padding: 16px;
    font-size: 17px;
    font-weight: 700;
    cursor: pointer;
  }
  .btn-primary { background: var(--accent); color: #fff; }
  .btn-secondary { background: var(--card); color: var(--text); border: 2px solid #ddd6cb; }
  .btn-block { width: 100%; }

  .spacer { flex: 1; }

  #video-wrap {
    position: relative;
    width: 100%;
    aspect-ratio: 3 / 4;
    background: #000;
    border-radius: 16px;
    overflow: hidden;
    margin-top: 16px;
  }
  #video, #captured { width: 100%; height: 100%; object-fit: cover; display: block; }

  .shutter-row { display: flex; justify-content: center; margin-top: 20px; }
  #shutter {
    width: 74px;
    height: 74px;
    border-radius: 50%;
    background: #fff;
    border: 5px solid var(--accent);
    padding: 0;
  }
  #shutter:active { background: #eee; }

  .btn-row { display: flex; gap: 12px; margin-top: 16px; }
  .btn-row button { flex: 1; }

  #camera-error { display: none; color: var(--ng); font-size: 14px; margin-top: 16px; text-align: center; }

  #loading { display: none; align-items: center; justify-content: center; gap: 10px; margin-top: 24px; }
  #loading.active { display: flex; }
  .spinner {
    width: 22px; height: 22px;
    border: 3px solid #e2dccf;
    border-top-color: var(--accent);
    border-radius: 50%;
    animation: spin 0.8s linear infinite;
  }
  @keyframes spin { to { transform: rotate(360deg); } }

  #result-box {
    margin-top: 24px;
    background: var(--card);
    border-radius: 12px;
    padding: 16px;
    box-shadow: 0 1px 3px rgba(0,0,0,0.06);
    font-size: 15px;
    line-height: 1.7;
    white-space: pre-wrap;
  }
  #result-box.error { color: var(--ng); }

  .topbar { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
  .topbar button { padding: 8px 14px; font-size: 14px; }
</style>

<div id="app">

  <section id="screen-home" class="screen active">
    <h1>防災袋 中身チェック</h1>
    <p class="sub">カメラで防災袋の中身を撮影して、AIが持ち物をチェックします。</p>

    <h2>AIモデル</h2>
    <div id="model-list"></div>

    <div class="spacer"></div>
    <button class="btn-primary btn-block" id="start-btn">撮影をはじめる</button>
  </section>

  <section id="screen-viewfinder" class="screen">
    <div class="topbar">
      <button class="btn-secondary" id="back-home-btn">← ホーム</button>
    </div>
    <h1>撮影</h1>
    <p class="sub">防災袋の中身が画面に収まるように撮影してください。</p>

    <div id="video-wrap">
      <video id="video" autoplay playsinline muted></video>
      <img id="captured" alt="撮影した写真" hidden>
    </div>
    <canvas id="canvas" hidden></canvas>

    <div class="shutter-row" id="shutter-row">
      <button id="shutter" aria-label="撮影"></button>
    </div>

    <div class="btn-row" id="capture-actions" hidden>
      <button class="btn-secondary" id="retake-btn">撮り直す</button>
      <button class="btn-primary" id="send-btn">送信</button>
    </div>

    <div id="camera-error"></div>

    <div id="loading">
      <div class="spinner"></div>
      <span>AIが確認しています…</span>
    </div>

    <div id="result-box" hidden></div>
  </section>

</div>

<script>
(function () {
  var MODELS = [
    { value: "gpt-5.6", label: "GPT-5.6" },
    { value: "gpt-5.4-mini", label: "GPT-5.4 mini" }
  ];
  var state = { model: MODELS[0].value, stream: null, busy: false, captured: null };

  function $(id) { return document.getElementById(id); }
  function showScreen(id) {
    document.querySelectorAll(".screen").forEach(function (el) { el.classList.remove("active"); });
    $(id).classList.add("active");
  }

  var modelList = $("model-list");
  MODELS.forEach(function (m, i) {
    var wrap = document.createElement("label");
    wrap.className = "model-option" + (i === 0 ? " selected" : "");
    wrap.innerHTML =
      '<input type="radio" name="model" value="' + m.value + '"' + (i === 0 ? " checked" : "") + '>' +
      '<span class="label">' + m.label + '</span>';
    wrap.querySelector("input").addEventListener("change", function () {
      state.model = m.value;
      document.querySelectorAll(".model-option").forEach(function (o) { o.classList.remove("selected"); });
      wrap.classList.add("selected");
    });
    modelList.appendChild(wrap);
  });

  $("start-btn").addEventListener("click", function () {
    showScreen("screen-viewfinder");
    startCamera();
  });
  $("back-home-btn").addEventListener("click", function () {
    stopCamera();
    showScreen("screen-home");
  });

  var video = $("video");
  var captured = $("captured");
  var canvas = $("canvas");
  var shutterRow = $("shutter-row");
  var captureActions = $("capture-actions");
  var sendBtn = $("send-btn");

  function pickMainBackCameraId(devices) {
    var backCams = devices.filter(function (d) {
      return d.kind === "videoinput" && /back|rear|environment/i.test(d.label);
    });
    if (!backCams.length) return null;
    var main = backCams.find(function (d) {
      return !/ultra|wide angle|telephoto|zoom/i.test(d.label);
    });
    return (main || backCams[0]).deviceId;
  }

  function startCamera() {
    $("camera-error").style.display = "none";
    showLive();
    if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
      cameraUnavailable();
      return;
    }
    navigator.mediaDevices.getUserMedia({
      video: {
        facingMode: { ideal: "environment" },
        width: { ideal: 1920 },
        height: { ideal: 1080 }
      },
      audio: false
    }).then(function (stream) {
      return navigator.mediaDevices.enumerateDevices().then(function (devices) {
        var mainId = pickMainBackCameraId(devices);
        var currentId = stream.getVideoTracks()[0].getSettings().deviceId;
        if (!mainId || mainId === currentId) return stream;
        stream.getTracks().forEach(function (t) { t.stop(); });
        return navigator.mediaDevices.getUserMedia({
          video: {
            deviceId: { exact: mainId },
            width: { ideal: 1920 },
            height: { ideal: 1080 }
          },
          audio: false
        });
      });
    }).then(function (stream) {
      state.stream = stream;
      video.srcObject = stream;
    }).catch(cameraUnavailable);
  }

  function cameraUnavailable() {
    $("camera-error").textContent = "カメラを起動できませんでした。";
    $("camera-error").style.display = "block";
    shutterRow.hidden = true;
  }

  function stopCamera() {
    if (state.stream) {
      state.stream.getTracks().forEach(function (t) { t.stop(); });
      state.stream = null;
    }
  }

  $("shutter").addEventListener("click", function () {
    if (state.busy || !state.stream) return;
    capture();
  });

  function capture() {
    var maxDim = 1024;
    var scale = Math.min(1, maxDim / Math.max(video.videoWidth, video.videoHeight));
    canvas.width = video.videoWidth * scale;
    canvas.height = video.videoHeight * scale;
    canvas.getContext("2d").drawImage(video, 0, 0, canvas.width, canvas.height);
    state.captured = canvas.toDataURL("image/jpeg", 0.75);

    captured.src = state.captured;
    captured.hidden = false;
    video.hidden = true;
    shutterRow.hidden = true;
    captureActions.hidden = false;
    sendBtn.hidden = false;
  }

  function showLive() {
    state.captured = null;
    captured.hidden = true;
    video.hidden = false;
    shutterRow.hidden = false;
    captureActions.hidden = true;
    $("result-box").hidden = true;
  }

  $("retake-btn").addEventListener("click", showLive);
  $("send-btn").addEventListener("click", function () {
    sendBtn.hidden = true;
    analyze(state.captured);
  });

  function analyze(imageDataUrl) {
    state.busy = true;
    $("loading").classList.add("active");
    $("result-box").hidden = true;

    fetch("/analyze", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ image: imageDataUrl, model: state.model })
    })
      .then(function (res) { return res.json().then(function (data) { return { ok: res.ok, data: data }; }); })
      .then(function (r) {
        if (r.ok) {
          renderResult(r.data.text, false);
        } else {
          renderResult(r.data.error || "エラーが発生しました", true);
        }
      })
      .catch(function (err) {
        renderResult("通信エラーが発生しました: " + err.message, true);
      })
      .finally(function () {
        state.busy = false;
        $("loading").classList.remove("active");
      });
  }

  function renderResult(text, isError) {
    var box = $("result-box");
    box.textContent = text;
    box.classList.toggle("error", isError);
    box.hidden = false;
  }
})();
</script>

このHTMLでは、スマホのカメラ映像を表示し、シャッターボタンを押して、確認後は画像をLambdaへ送信します。Lambdaから返ってきた結果文は、画面内チャットに表示します。

・2つファイル(lambda_function.pyと1つのHTML)を手元に準備できたら、Lambda関数にセットします。

AWSコンソールのLambda内コードソースは以下のようになっていればOKです。

あわせて、OpenAI GPT APIを使用するためのAPIキーを環境変数に設定します。
・「設定」→「環境変数」→「編集」からキー情報を入力し、「保存」で設定してください。

※APIキーの取得方法は割愛します。必要であれば、OpenAI公式ドキュメントをご参照ください。→OpenAI docs

最後に「Deploy」を押して、コードをデプロイします。

以下の確認メッセージが表示されればOKです。

これでアプリ作成は完了です。

3.実験


早速試してみます。
自宅にある防災袋の中身をスキャンしてみます。

・スマホのブラウザ(Chrome)から関数URLへアクセスします。

起動された!ホーム画面が表示された。
ここから、2つのAIモデルを使って実際に不足品の判定(推論)を試してみます。

GPT-5.6-sol
・AIモデルを「gpt-5.6」に設定し、「撮影をはじめる」から撮影後、「送信」を押します。

無事に動きました。アプリの動作は期待どおりです。
処理には約26秒かかり、結果は以下の通りでした。判定精度は、まあまあですね。

結果
基本的な防災用品はありますが、追加が必要です。
災害の『備え』チェックリストと比較すると、24アイテム中11個が不足しているようです。詳細は以下のとおりです。
不足しているのは、食品、防災用ヘルメット・防災ずきん、衣類・下着、紐なしのズック靴、携帯ラジオ、救急用品、使い捨てカイロ、洗面用具、石けん・ハンドソープ、体温計、貴重品です。なお、画像の履物はスリッパのため、避難用のズック靴としては不足と判断しました。
他の13個アイテムは準備OKでした。

GPT-5.4 mini
・AIモデルを「gpt-5.4 mini」に設定し、「撮影をはじめる」から撮影後、「送信」を押します。

GPT-5.4 miniは、GPT-5.6 Solよりも速く、約4秒で結果が表示されました。
判定精度はまあまあですが、水など、入っている用品を不足と判定した点は気になります。

結果
かなり充実しています。 災害の『備え』チェックリストと比較すると、24アイテム中13個が不足しているようです。詳細は以下のとおりです。 不足している項目は、水、食品、防災用ヘルメット・防災ずきん、衣類・下着、レインウェア、紐なしのズック靴、携帯ラジオ、予備電池・携帯充電器、マッチ・ろうそく、使い捨てカイロ、ブランケット、洗面用具、貴重品です。 他の11個アイテムは準備OKでした。

以上で実験は完了です。
次の項で、2つのモデルの結果を比較・整理します。

4.考察


2つのモデルの実行結果を、もう少し丁寧に整理します。

前項の「11件」「13件」は、AIが不足と判定した総数であり、実際には存在する用品も含まれています。ここでは、正解となる不足品13項目を発見できたかで比較します。

比較項目GPT-5.6 SolGPT-5.4 mini
不足品指摘率 ※正解不足品=139/13
(約69%)
7/13
(約54%)
不足品1:食品発見成功発見成功
不足品2:防災用ヘルメット・防災ずきん発見成功発見成功
不足品3:ひもなしのズック靴発見成功発見成功
不足品4:携帯ラジオ発見成功発見成功
不足品5:マッチ・ろうそく発見失敗発見成功
不足品6:救急用品発見成功発見失敗
不足品7:使い捨てカイロ発見成功発見成功
不足品8:軍手発見失敗発見失敗
不足品9:歯磨き粉発見失敗発見失敗
不足品10:手指消毒用アルコール発見失敗発見失敗
不足品11:石けん・ハンドソープ発見成功発見失敗
不足品12:体温計発見成功発見失敗
不足品13:貴重品発見成功発見成功
処理時間約26秒約4秒
費用約6.46円/回約0.25円/回

両モデルとも精度はまあまあで、GPT-5.6 Solはやや高精度、GPT-5.4 miniは高速・低コストでした。

まとめ


高性能なVLMを使って、防災袋の中身チェックアプリを作成し、精度を確認してみました。

結果は、両モデルとも不足品の見落としがあり、精度はまだ十分とは言えませんでした。モデルに与えるプロンプトに、アイテムの補足情報を丁寧に記述してあげたら対策になるかもしれませんね。今度試してみます。

費用面では、GPT-5.6 Solは単価差が約6.7倍でも、思考トークンを含む実料金は約26倍となりました。見積もり時には注意が必要ですね。

ನಿರ್ದಿಷ್ಟ ಅಗತ್ಯಗಳಿಗೆ ಸರಿಯಾದ AI ಮಾದರಿಯನ್ನು ಹೇಗೆ ಆಯ್ಕೆ ಮಾಡುವುದು ಎಂಬುದರ ಕುರಿತು ನಾವು ತನಿಖೆಯನ್ನು ಮುಂದುವರಿಸುತ್ತೇವೆ.
(今後も、用途に合ったAIモデルの選び方を検証していきたいと思います。)

ご連絡フォーム


フィードバックを是非お願いします。
本記事の方法での問題点や、よりよい方法のアイデアを頂けると大変助かります。

この記事に関して

その他のご連絡

DevAIsをもっと見る

今すぐ購読し、続きを読んで、すべてのアーカイブにアクセスしましょう。

続きを読む