LM Studio SDKで写真自動タグ付けを実現 ─ Vision推論は「Qwen3-VL」が「Gemma3」より4倍速かった

LM Studioで写真自動タグ付けを実現 ─ Vision推論は「Qwen3-VL」が「Gemma3」より4倍速かった HowTo

人の手で扱える“AIの視覚”──巨大モデルの時代は、ローカルへ縮小した。

ローカル環境でのVision推論は実運用に直結する。今回は LM Studio 上で qwen/qwen3-vl-4bgoogle/gemma-3-4b-it を同条件で比較し、画像→説明→JSON化→速度計測(p50/p95含む)まで実施した。計測結果と再現可能な実行手順を提示する。


概要と結論(要点)

  • 結論:今回の環境では Qwen3-VL が平均約 1.7秒、Gemma-3-4b-it が約 6.4秒 で、Qwen が約 3.7倍 高速だった。
  • 用途の棲み分け:Qwen はタグ抽出・メタデータ生成向き。Gemma は表現豊かなキャプション生成向き。実運用は両者を組み合わせるのが効率的。

テキスト推論で無類のスピードを誇った、Qwen3-VL-4B が Vision推論でもスピードキングの座を守った。

▶ その他の Qwen の記事はこちら


計測方法(再現性重視)

  1. 環境:
    Windows11 + LM Studio 0.3.31(ローカル) + Python(lmstudio パッケージ)
    Intel core i7 8700 / RTX3060 12GB / MEM 32GB (DDR4-2666)
  2. ワークフロー:prepare_image(path)Chat.add_user_message(images=[...])model.respond(chat)
  3. 取得値:各画像ごとに prepare / respond / totaltime.perf_counter() で計測、output.jsonltimings_*.csv に保存
  4. ウォームアップ実施:モデルロードや初回遅延を吸収するため、最初に1回のウォームアップ呼び出しを行った

計測スクリプト

実際に計測に使ったのは以下の最短ルート(抜粋)です。公式サンプルや OpenAI 互換ルートでは 400/500 エラーが出る等の問題があり、今回の比較はこの「動作確認済みルート」を用いて行いました。

#!/usr/bin/env python3
# mini_jsonify_timing.py
# - lmstudio を使って画像を処理 (prepare_image -> model.respond)
# - 各画像ごとに time.perf_counter で計測(prepare, respond, total)
# - 出力: output.jsonl (既存スキーマ), timings.csv (file,prepare_ms,respond_ms,total_ms,timestamp)
# - 最初にウォームアップ呼び出しを行い、1回目のコールド遅延を吸収します。
#
# 実行: python mini_jsonify_timing.py

import lmstudio as lms
from pathlib import Path
import json, time, csv, statistics, datetime, sys, traceback, re

BASE_MODEL = "qwen/qwen3-vl-4b"   # "qwen/qwen3-vl-4b" or "gemma-3-4b-it" — 測りたいモデルに切替可能
INPUT_DIR = Path("./input")
OUT_FILE = Path("output.jsonl")
TIMINGS_CSV = Path("timings.csv")

# --- パーサ ---
LOCATION_KEYWORDS = ["床", "絨毯", "carpet", "background", "壁", "ドア", "室内", "屋内", "屋外", "テーブル", "ベッド"]

def normalize_text_from_pred(pred):
    try:
        if hasattr(pred, "content"):
            txt = pred.content
        else:
            txt = str(pred)
    except Exception:
        txt = str(pred)
    if txt is None:
        txt = ""
    return str(txt).strip()

def parse_items_lines(text):
    lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
    items = []
    other_lines = []
    for ln in lines:
        m = re.match(r"^[-–—•\*]\s*(.+)$", ln)
        if m:
            items.append(m.group(1).strip())
        else:
            if "," in ln and len(items) == 0:
                parts = [p.strip() for p in ln.split(",") if p.strip()]
                if len(parts) > 1:
                    items.extend(parts)
                    continue
            other_lines.append(ln)
    return items, other_lines

def detect_location(text):
    for kw in LOCATION_KEYWORDS:
        if kw in text:
            idx = text.find(kw)
            start = max(0, idx - 20)
            end = min(len(text), idx + len(kw) + 20)
            return text[start:end].strip()
    return ""

def to_fixed_schema(image_name, pred):
    raw = normalize_text_from_pred(pred)
    objects, other = parse_items_lines(raw)
    scene = ""
    if other:
        scene = other[0]
    elif objects:
        scene = objects[0]
    else:
        scene = (raw[:80] + "...") if len(raw) > 80 else raw
    location = detect_location(raw + " " + " ".join(objects))
    detail = ""
    if len(other) >= 2:
        detail = " ".join(other[1:])
    elif len(other) == 1 and objects:
        detail = other[0]
    else:
        detail = ""
    return {
        "file": image_name,
        "scene": scene,
        "location": location,
        "objects": objects,
        "detail": detail,
        "raw": raw
    }

# --- LM 呼び出しラッパ ---
def describe_with_model_timed(image_path: Path, model_name: str):
    timings = {"prepare": None, "respond": None, "total": None}
    start_total = time.perf_counter()
    # prepare
    t0 = time.perf_counter()
    try:
        img_handle = lms.prepare_image(str(image_path))
    except Exception as e:
        raise RuntimeError(f"prepare_image failed: {e}")
    t1 = time.perf_counter()
    timings["prepare"] = (t1 - t0) * 1000.0
    # model
    try:
        model = lms.llm(model_name)
    except Exception as e:
        raise RuntimeError(f"llm() failed: {e}")
    chat = lms.Chat()
    # try common arg names
    try:
        chat.add_user_message("この画像に写っているものを短く日本語で説明してください。重要なオブジェクトを箇条書きで列挙してください。", images=[img_handle])
    except Exception:
        try:
            chat.add_user_message("この画像に写っているものを短く日本語で説明してください。重要なオブジェクトを箇条書きで列挙してください。", image=img_handle)
        except Exception as e:
            raise RuntimeError(f"chat.add_user_message failed: {e}")
    # respond timing
    t2 = time.perf_counter()
    try:
        resp = model.respond(chat)
    except Exception as e:
        # capture exception trace to aid debugging
        tb = traceback.format_exc()
        raise RuntimeError(f"model.respond failed: {e}\n{tb}")
    t3 = time.perf_counter()
    timings["respond"] = (t3 - t2) * 1000.0
    timings["total"] = (t3 - start_total) * 1000.0
    return resp, timings

# --- ユーティリティ: ウォームアップ ---
def warmup(model_name: str, sample_image: Path = None):
    print("ウォームアップ実行中...(モデルロード & 初回コストを吸収します)")
    # do one minimal prepare + respond using either provided sample or skip respond if none
    try:
        if sample_image and sample_image.exists():
            # perform one timed call but ignore timings
            try:
                _resp, _t = describe_with_model_timed(sample_image, model_name)
                print("ウォームアップ完了(画像経路)")
            except Exception as e:
                # if respond fails, still attempt a model-only touch
                print("ウォームアップ(画像経路)で例外:", e)
        else:
            # try a lightweight textual call to ensure model is loaded
            model = lms.llm(model_name)
            chat = lms.Chat()
            chat.add_user_message("こんにちは。モデルのウォームアップ用の短いテキスト応答をください。")
            try:
                _ = model.respond(chat)
                print("ウォームアップ完了(テキスト経路)")
            except Exception as e:
                print("ウォームアップ(テキスト経路)で例外:", e)
    except Exception as e:
        print("ウォームアップ全体で例外:", e)

def summarize_stats(list_vals_ms):
    if not list_vals_ms:
        return {}
    vals = list_vals_ms
    return {
        "count": len(vals),
        "total_ms": sum(vals),
        "mean_ms": statistics.mean(vals),
        "median_ms": statistics.median(vals),
        "p95_ms": (sorted(vals)[int(len(vals)*0.95)-1] if len(vals)>0 else None)
    }

def main():
    imgs = sorted([p for p in INPUT_DIR.iterdir() if p.is_file()])
    if not imgs:
        print("input/ に画像がありません。")
        return

    # optional: use first image for warmup to reduce first-call overhead
    warm_sample = imgs[0] if imgs else None
    warmup(BASE_MODEL, sample_image=warm_sample)

    # prepare outputs
    OUT_FILE.parent.mkdir(parents=True, exist_ok=True)
    timings_rows = []
    prepare_ms_list = []
    respond_ms_list = []
    total_ms_list = []

    with OUT_FILE.open("w", encoding="utf-8") as fout, TIMINGS_CSV.open("w", encoding="utf-8", newline="") as csvf:
        csvw = csv.writer(csvf)
        csvw.writerow(["file","prepare_ms","respond_ms","total_ms","timestamp_iso"])
        for p in imgs:
            print("="*60)
            print("処理:", p.name)
            start_all = time.perf_counter()
            try:
                pred, t = describe_with_model_timed(p, BASE_MODEL)
            except Exception as e:
                print("  → 失敗:", e)
                # still record a failed row with zeros
                now = datetime.datetime.utcnow().isoformat() + "Z"
                csvw.writerow([p.name, "", "", "", now])
                continue
            # convert to schema
            record = to_fixed_schema(p.name, pred)
            fout.write(json.dumps(record, ensure_ascii=False) + "\n")
            # write timing
            now = datetime.datetime.utcnow().isoformat() + "Z"
            csvw.writerow([p.name, f"{t['prepare']:.3f}", f"{t['respond']:.3f}", f"{t['total']:.3f}", now])
            csvf.flush()
            # collect stats
            prepare_ms_list.append(t['prepare'])
            respond_ms_list.append(t['respond'])
            total_ms_list.append(t['total'])
            print(f"  prepare: {t['prepare']:.1f} ms, respond: {t['respond']:.1f} ms, total: {t['total']:.1f} ms")

    # summary
    print("\n計測サマリ:")
    ps = summarize_stats(prepare_ms_list)
    rs = summarize_stats(respond_ms_list)
    ts = summarize_stats(total_ms_list)
    print("prepare  (ms):", ps)
    print("respond  (ms):", rs)
    print("total    (ms):", ts)
    print("\n保存: ", OUT_FILE.resolve(), TIMINGS_CSV.resolve())

if __name__ == "__main__":
    main()

結果(要旨)

速度比較

モデル平均応答時間 (total_ms)中央値最速最遅p95
Qwen3-VL-4B1,728 ms1,890 ms902 ms2,229 ms2,187 ms
Gemma-3-4b-it6,456 ms6,493 ms4,653 ms8,184 ms8,039 ms

数値は同一マシン、同一SDKバージョンでの測定結果であり、ハードウェアや量子化方式によって変化します。
今回の差はモデル設計(推論経路・エンコーダの負荷)によるものである可能性が高いです。
※p95は簡易的な算出式を使用

"p95_ms": (sorted(vals)[int(len(vals)*0.95)-1] if len(vals)>0 else None)

テストに使用した画像

出力の性格(観察)

Qwen 3-VL-4B の出力

箇条的で物体列挙に強い(例:- 茶色の犬, - 首輪)。そのままタグや検索メタデータとして使いやすい。

{"file": "2025-11-10_19-14-11_6912.png", "scene": "茶色の犬", "location": "- 犬が寝ているカーペット\n- 背景のドア枠 茶色の犬 犬の顔 犬の耳 犬の鼻 犬", "objects": ["茶色の犬", "犬の顔", "犬の耳", "犬の鼻", "犬の前足", "犬の首に巻かれた首輪", "犬が寝ているカーペット", "背景のドア枠"], "detail": "", "raw": "- 茶色の犬\n- 犬の顔\n- 犬の耳\n- 犬の鼻\n- 犬の前足\n- 犬の首に巻かれた首輪\n- 犬が寝ているカーペット\n- 背景のドア枠"}
{"file": "2025-11-10_19-14-17_1323.png", "scene": "茶色と白の毛を持つ犬", "location": "向きに垂れている\n- 前足を前に伸ばして床に置いている\n- 背景はぼかされており、", "objects": ["茶色と白の毛を持つ犬", "顔の中央に白い毛のライン", "黒い鼻と大きな瞳", "両耳が下向きに垂れている", "前足を前に伸ばして床に置いている", "背景はぼかされており、部屋の内装が見える"], "detail": "", "raw": "- 茶色と白の毛を持つ犬\n- 顔の中央に白い毛のライン\n- 黒い鼻と大きな瞳\n- 両耳が下向きに垂れている\n- 前足を前に伸ばして床に置いている\n- 背景はぼかされており、部屋の内装が見える"}
{"file": "2025-11-10_19-14-23_9268.png", "scene": "茶色の犬", "location": "目は優しく、落ち着いた表情\n- 木目調の床に横たわっている 茶色の犬 鼻と口元に白", "objects": ["茶色の犬", "鼻と口元に白い毛", "顔を前で手で支えている", "目は優しく、落ち着いた表情", "木目調の床に横たわっている"], "detail": "", "raw": "- 茶色の犬\n- 鼻と口元に白い毛\n- 顔を前で手で支えている\n- 目は優しく、落ち着いた表情\n- 木目調の床に横たわっている"}
{"file": "2025-11-10_19-14-29_1819.png", "scene": "茶色の犬", "location": "- 茶色の犬\n- 顔を床にのせて休んでいる\n- 眼が大きく、優し", "objects": ["茶色の犬", "顔を床にのせて休んでいる", "眼が大きく、優しい表情をしている", "両耳は下がっており、柔らかい毛並み", "鼻と口元は黒っぽく、白い毛が少し見える"], "detail": "", "raw": "- 茶色の犬\n- 顔を床にのせて休んでいる\n- 眼が大きく、優しい表情をしている\n- 両耳は下がっており、柔らかい毛並み\n- 鼻と口元は黒っぽく、白い毛が少し見える"}

Gemma-3-4b の出力

自然文での表現が得意(例:「落ち着いた表情で〜」)。人が読む本文やキャプションとして使いたいときに有利。

{"file": "2025-11-10_19-14-11_6912.png", "scene": "この画像には、茶色の犬がカーペットの上に横たわっています。落ち着いた表情で何かを見つめています。", "location": "**カーペット:** 犬が横たわっている床の覆い\n*   **首輪:** 犬の首に", "objects": ["**犬:** 茶色をした大型の犬(おそらくラブラドールレトリーバー)", "**カーペット:** 犬が横たわっている床の覆い", "**首輪:** 犬の首に巻かれた赤い首輪"], "detail": "重要なオブジェクト:", "raw": "この画像には、茶色の犬がカーペットの上に横たわっています。落ち着いた表情で何かを見つめています。\n\n重要なオブジェクト:\n\n*   **犬:** 茶色をした大型の犬(おそらくラブラドールレトリーバー)\n*   **カーペット:** 犬が横たわっている床の覆い\n*   **首輪:** 犬の首に巻かれた赤い首輪"}
{"file": "2025-11-10_19-14-17_1323.png", "scene": "この画像には、茶色と白の毛並みのミックスされた犬がリラックスして座っています。表情は穏やかで、カメラを見つめています。", "location": "あり、メインの被写体です。\n*   **床:** 犬が座っている場所です。\n*", "objects": ["**犬:** 画像の中心であり、メインの被写体です。", "**床:** 犬が座っている場所です。", "**カーペット:** 床を覆う赤い色のカーペットです。"], "detail": "重要なオブジェクト:", "raw": "この画像には、茶色と白の毛並みのミックスされた犬がリラックスして座っています。表情は穏やかで、カメラを見つめています。\n\n重要なオブジェクト:\n\n*   **犬:** 画像の中心であり、メインの被写体です。\n*   **床:** 犬が座っている場所です。\n*   **カーペット:** 床を覆う赤い色のカーペットです。"}
{"file": "2025-11-10_19-14-23_9268.png", "scene": "この画像には、茶色の犬が床に横たわっています。表情が少し憂鬱な様子です。", "location": "この画像には、茶色の犬が床に横たわっています。表情が少し憂鬱な様子", "objects": ["犬(ベガルド)", "床", "爪"], "detail": "重要なオブジェクト:", "raw": "この画像には、茶色の犬が床に横たわっています。表情が少し憂鬱な様子です。\n\n重要なオブジェクト:\n\n*   犬(ベガルド)\n*   床\n*   爪"}
{"file": "2025-11-10_19-14-29_1819.png", "scene": "この画像には、茶色の犬が床に横たわっています。少し悲しげな表情をしており、可愛らしい姿です。", "location": "この画像には、茶色の犬が床に横たわっています。少し悲しげな表情をし", "objects": ["犬(茶色)", "床(木目調)", "青いブランケット"], "detail": "重要なオブジェクト:", "raw": "この画像には、茶色の犬が床に横たわっています。少し悲しげな表情をしており、可愛らしい姿です。\n\n重要なオブジェクト:\n\n*   犬(茶色)\n*   床(木目調)\n*   青いブランケット"}

実務的提言

  1. 大量画像の自動整理・検索索引化 → Qwenで先にタグ化
  2. ポータルや記事のリード文生成 → Gemma による文章化(Qwenのタグをプロンプトに含める)。
  3. 実装では「ウォームアップ」「p50/p95 の確認」「モデル切替の自動化」を組み込む。

LM Studio で Vision 対応モデルの見分け方

「マイモデル」の一覧で”目玉”のアイコンがついているかどうかで見分けられます。

結論 ─ Vision推論は「速度」が正義

今回の結果はシンプルだ。

Gemmaは綺麗に書く。
Qwenは速く返す。

LM Studio における Vision ワークロードでは、
推論速度そのものが、ユーザー体験を決める。

写真が1枚ならGemmaでもかまわない。
だが、100枚・1000枚の自動タグ付けや、フォルダ分類に発展するなら、

Qwen 3-VL-4B は、迷わず “選べる速度” だった。

実行結果にはバラつきがあるが、p50 / p95 の両方で
Qwen が 3〜5倍速い という傾向は一貫していた。

  • Gemma → 丁寧で、言葉選びが綺麗。
  • Qwen → 即答する。処理が止まらない。

Vision推論を「実務で使う」なら、
時間を短くできるモデルが正しい選択になる

本記事のコードは「動作した最短ルート」であり、
コピペすれば あなたのローカルでも測定できる

AIを使い倒すとは、
モデルを信じることではなく、数字で判断すること だ。


次にやるべきこと

Vision推論が動いたら、次は「活用」だ。

Photo ディレクトリを AI が自動で分類し、フォルダへ仕分けする

次の記事では、このゴールまでコードを拡張する。

すでに、画像 → JSON化 → timings.csv という
パイプラインは完成している。

あとは JSON の sceneobjects を使って
フォルダへ自動移動するだけだ。

CLI化して「定期実行」までいける。

Vision推論が “遊び” から “仕事” になる瞬間を見せる。


Local AI doesn’t need to be slower than Cloud.
It only needs clear measurement and a purpose.