VLMで熊問題に対策

背景や目的


先日、営業からVLM案件の協力依頼を受け、一瞬VLMってなんだっけ?と思ってしまいました。普段良く使っているGPT-4.1が、画像も処理出来る事は知っているので、それがVLMかしら・・と思いつつ、画像処理(CV)の性能がどの程度なのかとても気になりました。

最近テレビで、毎日のように熊出没のニュースが増えていますね。
丁度良いので、熊を発見するAIによる物体認識のミニツールを作ってみることにします。

本記事では、VLMの事を少し整理し、Google Colab上で実装したコードと実験結果を紹介したいと思います。

VLMとは?


VLMという言葉が実はあまりピンとこなかったため、少し調べてみました。
結論的には、マルチモーダル/VLM/LLM といった用語の境界は、近年かなり曖昧になってきたようです。モデルの進化に対して名称が追いついておらず、用語だけを見ると少し分かりづらい状況かもしれません。

VLM(Vision Language Model)は、Wikipediaでは
「画像や動画などの視覚情報とテキスト情報を統合的に理解・処理できる AIモデル」
と定義されています。

一方で、マルチモーダルLLMも、テキストに加えて画像など複数の形式の情報を扱えるモデルを指します。現在では実装や使い方の観点で両者の差はほとんどなく、呼び方が異なるだけで、指しているものはほぼ同じと考えて差し支えなさそうです。

整理すると、どちらも呼び方が違うけど実質的には同じ、と考えてよさそうです。
ちなみに今回は、マルチモーダルLLMでありVLMでもあるGPT-4.1 APIを使う事にします。

試作するツール


冒頭で触れた通り熊対策をしたいので、熊を発見するツールを試作します。
熊は怖いので、ドローン撮影を想定した静止画を使って、GPT-4.1で熊の有無を判定する機能を開発します。具体的な機能は以下の通りです。

  1. 評価用画像登録
    • 3つの異なるエリアを撮影した想定で、画像を用意して登録します。
    • 画像ファイル名には、撮影地点を表す緯度・経度と撮影時刻を含めます。以下、そのルール。
      IMG_<エリアNo>_<緯度>_<経度>_<YYYYMMDD>_<HHMM>.png
      例:IMG_1_35.65867638952187_139.74510030608872_20251219_1430.png
  2. 発見処理
    上記の3つエリアの画像をGPT-4.1へ推論し、解析を行います。
  3. 結果表示
    発見処理の結果を、以下書式で表示。
    – エリア:山中/里山/市街地
    – 結果:いる/いない
    – 頭数:いる場合の数
    – 詳細:撮影した画像の住所と日時

まずは、画像を準備しましょう。

ドローンが撮影した画像


実際に撮影するのは難しいので、Gemini 3 Pro Image(Nano Banana Pro ) で生成した静止画を用意しました。各エリアから1枚ずつ持ってきたイメージです。

1.IMG_1_36.243291047333976_137.6538881672855_20251219_1400.png 
エリア1:(山中)

熊は2頭いるね。

2.IMG_2_36.127453482727184_137.63021075324238_20251219_1430.png 
エリア2:(里山)

1頭いるね。

3.IMG_3_36.24678054268237_137.9549634060749_20251219_1500.png 
エリア3:(市街地)

いるね。こわ。

4.IMG_3_36.24348509716091_137.97369485421552_20251219_1530.png 
エリア3:(市街地)

いなそうですね。(検出の難易度を上げるため、犬を複数配置した)

画像準備完了!次に進みましょう。

ミニツールのコード


冒頭で触れた通り今回はGoogle Colab上で動作させます。処理は次の通りです。
・画像ファイル名から、エリア、位置情報、撮影時刻を取得。
・GPT-4.1により熊の有無と頭数を判定。
・結果をPandasで表形式て出力。

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

Google Colab
#install
!pip -q install OpenAI

import os
import re
import base64
import pandas as pd
from openai import OpenAI

client = OpenAI(api_key="sk-***")

# -------------------------------------------------
# 入力画像(/content にアップロード済み)
# -------------------------------------------------
image_paths = [
    "/content/IMG_1_36.243291047333976_137.6538881672855_20251219_1400.png", # 山中
    "/content/IMG_2_36.127453482727184_137.63021075324238_20251219_1430.png", # 里山
    "/content/IMG_3_36.24678054268237_137.9549634060749_20251219_1500.png", # 市街地
    "/content/IMG_3_36.24348509716091_137.97369485421552_20251219_1530.png", # 市街地
]

QUESTION = "添付した画像内で熊はありますでしょうか?数えてください。"

# -------------------------------------------------
# ファイル名から情報を取得
# -------------------------------------------------
def parse_area_no(path: str):
    name = os.path.basename(path)
    m = re.match(r"IMG_(\d+)_", name)
    return int(m.group(1)) if m else None

def parse_gps_and_time(path: str):
    name = os.path.splitext(os.path.basename(path))[0]
    # IMG_<areaNo>_<lat>_<lon>_<YYYYMMDD>_<HHMM>.png を想定
    m = re.search(r"IMG_(\d+)_(-?\d+\.\d+)_(-?\d+\.\d+)_(\d{8})_(\d{4})", name)
    if not m:
        return None, None, None, name
    area_no = int(m.group(1))
    lat = float(m.group(2))
    lon = float(m.group(3))
    time_str = f"{m.group(4)}_{m.group(5)}"
    return area_no, lat, lon, time_str

def classify_location_by_area_no(area_no: int) -> str:
    if area_no == 1:
        return "山中"
    elif area_no == 2:
        return "里山"
    elif area_no == 3:
        return "市街地"
    else:
        return "不明"

# -------------------------------------------------
# 熊の頭数を推論(画像のみ)
# -------------------------------------------------
def ask_bear_count(image_path: str):
    with open(image_path, "rb") as f:
        image_b64 = base64.b64encode(f.read()).decode("utf-8")

    prompt = f"""
質問: {QUESTION}

出力は数字1つだけ。
- 熊がいない: 0
- 熊がいる: 1以上の整数(頭数)
- 判定不能: -1
"""

    response = client.responses.create(
        model="gpt-4.1",
        input=[{
            "role": "user",
            "content": [
                {"type": "input_text", "text": prompt.strip()},
                {"type": "input_image", "image_url": f"data:image/png;base64,{image_b64}"},
            ],
        }],
    )

    text = (response.output_text or "").strip()
    m = re.search(r"-?\d+", text)
    if not m:
        return None

    n = int(m.group(0))
    return None if n == -1 else max(0, n)

PATH_TO_ADDRESS = {
    image_paths[0]: "長野県松本市安曇***",
    image_paths[1]: "長野県松本市安曇***",
    image_paths[2]: "長野県松本市中央***",
    image_paths[3]: "長野県松本市深志***",
}

# -------------------------------------------------
# 実行 & 表作成
# -------------------------------------------------
rows = []

for path in image_paths:
    area_no, lat, lon, time_str = parse_gps_and_time(path)

    location = classify_location_by_area_no(area_no) if area_no is not None else "不明"
    result = "不明"
    count = "-"

    if not os.path.exists(path):
        result = "エラー"
        count = "-"
    else:
        c = ask_bear_count(path)
        if c is None:
            result = "不明"
            count = "-"
        elif c == 0:
            result = "安全"
            count = "-"
        else:
            result = "注意"
            count = c

    rows.append({
        "結果": result,
        "頭数": count,
        "エリア": location,
        "時刻": time_str,
        "住所": PATH_TO_ADDRESS.get(path, "長野県松本市"),
        "image_path": path,
    })

df = pd.DataFrame(rows)
df.index = range(1, len(df) + 1)
#df

from datetime import datetime
from PIL import Image
from IPython.display import HTML, display

def filename_to_datetime_str(time_str: str) -> str:
    try:
        dt = datetime.strptime(time_str, "%Y%m%d_%H%M")
        return dt.strftime("%Y年%m月%d日 %H時%M分")
    except:
        return time_str

def make_detail(area: str, time_str: str) -> str:
    addr = AREA_TO_ADDRESS.get(area, "住所不明")
    return f"{addr} / {filename_to_datetime_str(time_str)}"

def make_thumbnail_html(image_path: str, max_w=180):
    if not image_path or not os.path.exists(image_path):
        return "<div style='color:#999'>画像なし</div>"

    img = Image.open(image_path).convert("RGB")
    w, h = img.size
    scale = max_w / float(w)
    img = img.resize((max_w, int(h * scale)))

    from io import BytesIO
    buf = BytesIO()
    img.save(buf, format="JPEG", quality=85)
    b64 = base64.b64encode(buf.getvalue()).decode("utf-8")
    return f"<img src='data:image/jpeg;base64,{b64}' style='width:{max_w}px; border-radius:10px; box-shadow:0 2px 10px rgba(0,0,0,0.15)'>"

df2 = df.copy()
df2["詳細"] = df2.apply(lambda r: f'{r["住所"]} / {filename_to_datetime_str(str(r["時刻"]))}', axis=1)
df2["サムネ"] = df2.apply(lambda r: make_thumbnail_html(r["image_path"]), axis=1)

df_view = df2[["エリア","結果","頭数","詳細","サムネ"]]

def style_result_cell(val):
    if val == "注意":
        return "background-color:#ff4d4d;color:white;font-weight:800;text-align:center;"
    if val == "安全":
        return "background-color:#2ecc71;color:white;font-weight:800;text-align:center;"
    return "background-color:#bdc3c7;color:white;font-weight:800;text-align:center;"

styled = (
    df_view.style
    .map(style_result_cell, subset=["結果"])  # applymap → map(警告回避)
    .set_properties(**{
        "font-size": "18px",
        "padding": "14px",
        "border": "1px solid #e5e7eb",
        "vertical-align": "middle",
    })
    .set_properties(subset=["頭数"], **{
        "text-align": "center",
    })
    .set_table_styles([
        {"selector":"th", "props":[
            ("font-size","18px"),
            ("font-weight","800"),
            ("background-color","#111827"),
            ("color","white"),
            ("padding","14px"),
            ("text-align","left"),
        ]},
        {"selector":"table", "props":[
            ("border-collapse","separate"),
            ("border-spacing","0"),
            ("border-radius","14px"),
            ("overflow","hidden"),
            ("box-shadow","0 10px 30px rgba(0,0,0,0.12)"),
        ]},
    ])
)

display(HTML(styled.to_html(escape=False)))

参照する4つの画像ファイルは、以下の通りColab画面左側のFilesにドラッグ&ドロップしてアップロードしておきます。その後、アップした各画像ファイルのパスを、上記のコードの15行目の「image_paths」に設定します。

また、コードの10行目にOpenAIのAPIキー情報を設定したら準備完了です。
APIキーの取得方法を知りたい場合は、こちらをご参照下さい。→OpenAI docs

さて、熊探索してみましょう!

熊探索結果


上記のコードセルを「shift+enter」で実行したら以下の表が出力された!

完璧ですね!

熊の有無は全て正解。頭数も合ってます。詳細欄はファイル名から読み取った内容です。
(注1)画像は実際に撮影したものではありません。実験用のダミー画像です。
(注2)住所は画像ファイル名の位置情報から固定表示していますが、これもダミーです。

補足情報:
内部処理の物体検出部はYOLO等とは異なりやっぱり言語モデルなので、文章が生成される点がポイントです。No.1の場合、GPT4.1は以下の文を生成しています。

まとめ


VLMのAPIを用いてミニツールを試作し、画像内の熊を検出し、頭数をカウントできることを確認しました。言語モデルなのでバウンディングボックスや確率は無いのですが、VLMの物体検出性能は想像以上に高く、意外と実用性がありそうだと感じました。

今回はドローン静止画像を用いた検証でしたが、ドローンを用いた実運用を考えると、連続フレームを単純に判定すると、同一熊を重複検出し、頭数がずれてしまう可能性がありますね。等々、本番ツールにする時には細部の作り込みは色々と必要でしょう。

こうした課題も念頭に置きつつ、次は動画ファイルを直接処理するようなより本番に近いツールにトライしてみたいと思います。

AIದು ಹೆಚ್ಚಿನ ಉಪಯೋಗಗಳನ್ನು ಅನ್ವೇಷಿಸೋಣ
(AIの使用をもっと見つけましょう)

ご連絡フォーム


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

・この記事に関して

・その他のご連絡

DevAIsをもっと見る

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

続きを読む