🖼️

Ollama×FastAPIで作る“ローカルVLMドキュメント解析Web UI”──画像→Markdown変換エンジンを自作する

に公開

はじめに

ルミナイR&Dチームの宮脇彰梧 です。
普段は大学院で マルチモーダルAI の研究を行いながら、
生成AIのアーキテクチャ、RAG、エージェント基盤、
また企業向けの PoC 実装を専門に活動しています。

僕が最近注目しているテーマのひとつが、

「クラウドに頼らず、ローカルだけで動くVLMの実用化」

です。

特に実務や研究室では、

  • 機密データを外部APIに出せない
  • コストを抑えて試作したい
  • オフライン環境で動かしたい
  • 小さなWebツールを自作して使いたい

という場面が多いはずです。

そこで本記事では、

Ollama(ローカルVLM)× FastAPI(Python)で
画像 → Markdown 変換Web UIを自作する

という、
“現場で使えるミニアプリケーション” を作りながら、
背景・調査・アーキテクチャまで含めて深掘りしていきます。

🔍 本記事で学べること

  • VLM内部構造の解説(画像エンコーダ×LLMの合体)
  • Ollamaの仕組みとAPI(/api/generate)の正しい使い方
  • FastAPIで画像アップロードUIを構築する手法
  • Markdown構造化のプロンプト設計

📚 記事の構成(深度UP版)

  1. 背景:なぜローカルVLMか?(市場動向 & 技術的背景)
  2. VLMとは何か?(内部アーキテクチャ & 限界)
  3. Ollamaの仕組み:ローカルLLM/VLM基盤の内部構造
  4. LLaVA系モデルの特性と実務適性
  5. FastAPIで構築するドキュメント解析Web UIの設計方針
  6. 実装(サーバ+クライアント)
  7. 品質検証:精度・速度
  8. 筆者の考察
  9. まとめ(本構成の価値と今後の方向性)

🎯 結論

Ollama×FastAPI×LLaVA という構成は、
企業や研究現場で実際に使える“ローカル文書解析基盤”になる。

特に以下の点が優れている:

  1. 完全ローカル/セキュア/コストゼロで動く
  2. 画像→Markdown構造へ変換する「前処理エンジン」として実用的
  3. RAG・ノートアプリと組み合わせれば業務用途にすぐ昇華可能

1. なぜローカルVLMか?(背景)

■ クラウドVLMの課題

Claude Opus 4 / Gemini 2.5 Pro / GPT-5 など、
2025 のクラウドVLMは非常に高性能です。

しかし現場では、以下の制約がしばしば問題になります:

  • APIキーの管理が厳密
  • NDA文書や技術資料を外部に送れない
  • トークン課金が高い
  • ネットワーク接続が不安定な環境が多い(研究室・工場)

特に 文書解析(スキャンPDF、設計図、研究ノート)
機密レベルが高い領域です。

■ ローカルVLMの利点

そこで注目されるのが Ollama + LLaVA です。

  • ローカルPCだけで動く
  • API構成はOpenAI互換で扱いやすい
  • GPU/Apple Siliconなら高速
  • モデルの差し替えが簡単

実務パワーは十分で、
“最低限のOCR+構造化” ができる 点が大きい。

2. VLMとは何か?(内部アーキテクチャ)

VLM(Vision-Language Model)はざっくり言うと:

「画像を理解できるLLM」

ですが、内部ではもっと複雑です。

以下は、“LLaVA内部がどう動いているか” を噛み砕いて説明したものです。

■ LLaVA系モデルの内部構造

  1. Vision Encoder
    → 画像→特徴ベクトル(視覚トークン)

  2. Projection Layer
    → 視覚トークンをLLMの埋め込み空間にマップ (線形変換 or MLP)

  3. LLMデコーダ
    → テキストとして応答を生成

つまり、

画像 → ViT → 視覚埋め込み → LLM(言語空間)→ テキスト出力

で動作しています。

🔍 これはつまり「画像を“言語の世界”へ翻訳する3段構造」

LLaVA系モデルは、

画像を直接LLM(言語モデル)に食わせるのではなく、
いったん「言語が理解できる形式」に変換してから推論させる

というアーキテクチャになっています。

LLMはそもそもテキストしか理解できないため、
画像を“文章の素(トークン)”のように変換する工程が必須になります。

その変換のパイプラインが、次の3つです:

① Vision Encoder

👉 画像を「意味ベクトル」(視覚トークン)に変換する

例:CLIP ViT-L

  • 入力:RGB画像
  • 出力:画像をパッチ(16×16 など)に切り分けた後、
    それぞれを高次元の特徴ベクトルに変換したもの

イメージ:

画像 → [v1, v2, v3, ..., vN] (= 視覚トークンの列)

これは「この部分は文字っぽい」「ここは表の罫線」「ここはタイトル行」
などを抽象的に表したベクトル群です。

② Projection Layer

👉 “視覚の世界”のベクトルを“言語の世界”のベクトルへマッピング

Vision Encoderの出力するベクトルは、
CLIP空間(視覚空間)という独自の意味空間にあります。

一方で、LLM(例:Vicuna)は 言語埋め込み空間 をもっています。

この2つは互換性がないため、
視覚→言語 の「翻訳」層が必要です。

それが Projection Layer(線形 or MLP) です。

イメージ:

視覚トークン **vᵢ(画像の意味ベクトル)**

↓(※ “画像の意味”をテキストが理解できる形に翻訳する)

言語空間のトークン **eᵢ(LLMが扱える疑似テキスト)**

つまり

画像の特徴を、テキストの単語のように扱える“疑似言語トークン”に変換する層

という感じ。

③ LLMデコーダ

👉 翻訳された“疑似言語トークン”を解釈して文章を生成する

Projection後の視覚トークン列は、
LLMにとって「特殊な文の先頭トークン群」のように扱われます。

例えばこんな感じで入力されます:

[<vision_token_1>, <vision_token_2>, ..., <vision_token_N>,  
  "describe", "this", "image", ...]

LLMは:

  • 画像トークンを読み取り
  • テキストトークンと同じ文脈で扱い
  • 最終的に文章を生成する

例:Vicuna-13B

→ “この図は3つの表で構成されており…”  
→ “タイトルは ‘~~’ で、左下にグラフがあります…”  

🌟 全体を図にするとこう

         画像
          │
          ▼
   [Vision Encoder]      ← 画像をパッチごとに埋め込み化
          │
     視覚トークン列
          │
          ▼
   [Projection Layer]     ← 言語空間へマッピング
          │
     言語トークン列
          │
          ▼
        LLM               ← テキスト生成(文章化・質問応答)

LLaVAは「画像→視覚トークン→言語トークン→LLM」という翻訳パイプラインを通して、
LLMが画像を読めるようにしている。

■ だからドキュメント解析に強い

  • 図の構造
  • 表の区分
  • 箇条書き
  • 見出しの視覚的特徴

などを視覚情報として捉え、
言語空間に落とし込めるためです。

OCRよりも高レベルの“概念理解”をします。

3. Ollamaの仕組み(内部構造・API)

Ollama は ローカルLLMサーバ として機能します。

  • モデルは ~/.ollama にキャッシュ
  • モデルは llama.cpp 系(GGUF)で実行
  • REST API で利用できる
  • 複数のモデルを同時管理可能

■ Vision対応エンドポイント


POST /api/generate
{
"model": "llava:13b",
"prompt": "...",
"images": ["<base64>"],
"stream": false
}

シンプルで扱いやすい。

Ollamaの特徴は、

Pythonサーバ(FastAPI)とも相性が良い設計になっている点。

4. LLaVAの特性と注意点

✔ 強いところ

  • テキスト構造の抽出(章構造・箇条書き)
  • 図をテキストで説明する
  • キャプション・概要生成

✔ 弱いところ

  • 小さい文字(8pt以下)は読みづらい
  • 表の再構成は精度がまちまち
  • 数式は壊れやすい

→ Markdownへの変換が一番安定している。

そのため本記事でも Markdown を選んでいます。

5. Web UI のアーキテクチャ設計

今回構築するのは以下の構成です。


[Browser]
↓ 画像送信 (fetch)
[FastAPI]
↓ 一時保存
[Ollama /api/generate]
↓ Markdownテキスト
[FastAPI]
↓ JSONレスポンス
[Browser] ← Markdown表示

要点:

  • “画像 → Markdown” はサーバ側で処理
  • フロント側は軽量HTMLで十分
  • 処理速度は GPU or M1/M2/M3 で大きく改善

用途としては、

  • スキャンPDFの構造化
  • 研究ノートのデジタル化
  • ホワイトボード写真から議事録生成

などが現場でよくある。

6. 実装(サーバ+クライアント)

📂 ディレクトリ構成

このアプリケーションは以下のような構成で配置します。

project_root/
│
├── app.py                     # FastAPI メインサーバ
├── ollama_client.py           # Ollama API 呼び出しロジック
│
├── static/                    # Web UI(HTML/JS/CSS)
│   └── index.html
│
├── venv/(任意)               # Python仮想環境
└── README.md(任意)

🚀 実行方法

Windows + Ollama CLI 版(zip)を前提とした最小手順です。

① Ollama(CLI版)を起動する

PowerShell で:

cd C:\ollama
.\ollama.exe serve

成功ログ例:

Listening on 127.0.0.1:11434
... CUDA backend loaded ...

この段階で Ollama が GPU を自動検出し、使える場合は自動的に利用します。

② FastAPI を起動する

別の PowerShell(新しいタブ/ウィンドウ)で:

cd project_root
uvicorn app:app --reload

ログ例:

Uvicorn running on http://127.0.0.1:8000
🚀 FastAPI started
💡 Ollama is using: GPU mode

③ Web UI にアクセスする

ブラウザで:

http://127.0.0.1:8000
  • 画像をアップロード
  • 「解析する」をクリック
  • 推論結果がリアルタイムで表示されます

これだけで Web UI × VLM(LLaVAなど) が動作します。

■ ollama_client.py(変換ロジック)

import base64
import requests
from pathlib import Path

OLLAMA_API_URL = "http://localhost:11434/api/generate"
MODEL_NAME = "llava:13b"

def encode_image_to_base64(image_path: Path) -> str:
    with image_path.open("rb") as f:
        return base64.b64encode(f.read()).decode("utf-8")

def build_vlm_prompt() -> str:
    return """
あなたはドキュメント画像を読み取り、その内容をMarkdownとして構造化するアシスタントです。

出力ルール:
- 見出しは `#`, `##`, `###`
- 箇条書きは `-`
- 表はMarkdownテーブル記法
- 余計な説明は不要、Markdownのみ
- 出力は必ず日本語で行う
"""

def analyze_image_with_ollama(image_path: Path) -> str:
    prompt = build_vlm_prompt()
    image_b64 = encode_image_to_base64(image_path)

    payload = {
        "model": MODEL_NAME,
        "prompt": prompt,
        "images": [image_b64],
        "stream": False
    }

    resp = requests.post(OLLAMA_API_URL, json=payload)
    resp.raise_for_status()
    return resp.json().get("response", "").strip()

■ app.py(FastAPIサーバ)

import time
import tempfile
from pathlib import Path
import subprocess

from fastapi import FastAPI, File, UploadFile
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles

from ollama_client import analyze_image_with_ollama

app = FastAPI()

# 静的ファイル
app.mount("/static", StaticFiles(directory="static"), name="static")

def check_ollama_mode():
    try:
        OLLAMA_CMD = r"C:\ollama\ollama.exe"

        result = subprocess.run(
            [OLLAMA_CMD, "list", "hardware"],
            capture_output=True,
            text=True
        )
        out = result.stdout.lower()

        if "nvidia" in out or "cuda" in out:
            return "GPU"
        return "CPU"

    except Exception as e:
        print("⚠️ GPU/CPU 判定に失敗:", e)
        return "不明"


@app.on_event("startup")
async def startup_event():
    print("🚀 FastAPI started")

    mode = check_ollama_mode()
    print(f"💡 Ollama is using: {mode} mode")



@app.get("/", response_class=HTMLResponse)
async def index():
    return Path("static/index.html").read_text(encoding="utf-8")


@app.post("/api/analyze")
async def analyze(file: UploadFile = File(...)):
    """画像を解析して Markdown を返す"""
    start_time = time.time()

    suffix = Path(file.filename).suffix or ".png"
    with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
        tmp_path = Path(tmp.name)
        tmp.write(await file.read())

    try:
        md = analyze_image_with_ollama(tmp_path)
    except Exception as e:
        return JSONResponse(status_code=500, content={"error": str(e)})
    finally:
        tmp_path.unlink(missing_ok=True)

    exec_time = time.time() - start_time
    print(f"⏱️ Execution time: {exec_time:.2f} sec")

    return {
        "markdown": md,
        "exec_time_sec": exec_time
    }

■ static/index.html(フロント)

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8" />
  <title>VLM ドキュメント解析ビューア</title>
  <style>
    body {
      font-family: system-ui;
      max-width: 960px;
      margin: 2rem auto;
      padding: 1rem;
    }
    .preview {
      max-width: 100%;
      margin-top: 1rem;
      border-radius: 8px;
    }
    pre {
      white-space: pre-wrap;
      background: #f2f2f2;
      padding: 1rem;
      border-radius: 8px;
    }
  </style>
</head>
<body>
  <h1>📄 VLM ドキュメント解析ビューア</h1>

  <form id="form">
    <input type="file" id="file" accept="image/*" required />
    <button type="submit">解析する</button>
  </form>

  <img id="preview" class="preview" style="display:none;" />

  <h2>出力 Markdown</h2>
  <button id="copy">コピー</button>
  <pre id="output"></pre>

  <script>
    const fileInput = document.getElementById("file");
    const preview = document.getElementById("preview");
    const output = document.getElementById("output");
    const copyBtn = document.getElementById("copy");

    fileInput.addEventListener("change", () => {
      const file = fileInput.files[0];
      preview.src = URL.createObjectURL(file);
      preview.style.display = "block";
    });

    document.getElementById("form").addEventListener("submit", async (e) => {
      e.preventDefault();
      const fd = new FormData();
      fd.append("file", fileInput.files[0]);

      const res = await fetch("/api/analyze", { method: "POST", body: fd });
      const data = await res.json();
      output.textContent = data.markdown || "解析に失敗しました";
    });

    copyBtn.addEventListener("click", async () => {
      await navigator.clipboard.writeText(output.textContent);
      alert("コピーしました");
    });
  </script>
</body>
</html>

7. 性能検証(精度・速度)

実際に以下のような画像で検証しました:

  • 川辺に浮かぶボートの画像
  • 滝の画像
  • プレゼンテーションの一部の画像

私の環境では、CPUしか認識されなかったため、CPUでの実行時間を記録します.

川辺に浮かぶボートの結果

結果:

以下がドキュメント画像の内容をMarkdownとして構造化したものです。

# 湖畔に乗り込んだボート
## 夜明け前の水面

- 白い垣根が水面を分断する
- 映画「絆の旅路」のような景色
- 湖に流れる川の河口からの温度変化であると思われる
⏱️ Execution time: 23.67 sec

結果:

# 滝と花

## 山中にある美しい滝と花が咲く場所

- ****:水が流れ落ちる場所で、自然の景観を楽しめます。
- ****:春や夏には、多くの花が山中の木々と岩面に咲いており、美しい景色を提供しています。
⏱️ Execution time: 25.25 sec

結果:

# 学校法と司法裁判

## 公式名称: School of Law and Justice

### プレゼンテーションスタートの時刻:未定(準備中)

- 本ドキュメントは、学校法と司法裁判に関する情報を提供します。

- このプレゼンテーションでは、法律に基づく司法裁判の重要性や適用方法について学習することが目的です。

- 参加者は、学校法や司法裁判に関連する知識を得ることができます。

### プレゼンテーションスタートの場所:未定(準備中)
⏱️ Execution time: 41.79 sec

8. 筆者の考察

今回の「Ollama × FastAPI × LLaVA」によるローカルVLMドキュメント解析は、
単なる “便利ツール” 以上に、今後のAIシステム設計の方向性をかなり象徴していると感じました。

ここでは、
①技術アーキテクチャとしての評価
②実務ツールとしてのリアリティ
③研究テーマとしての拡張可能性
の3つのレイヤーで整理してみます。

8.1 技術アーキテクチャとしての評価

■ 「VLM = なんでもわかる」ではなく「構造抽出器」として見る

触っていて一番強く感じたのは、
LLaVA系VLMを 「万能な認識器」ではなく「構造抽出に特化したコンポーネント」 として捉えると、
挙動がかなり理解しやすく、設計が安定するということです。

  • 細かい文字認識 → 正直、専用OCRに劣る
  • 見出し・段落・“どこが重要か” → LLMの言語能力と組み合わさって非常に強い
  • 図・写真・UI画面 → 「何を表しているか」というレベルの理解ができる

つまり、「画素レベルの正確さ」より「概念レベルのまとまり」を得意とするモジュール になっている。

この前提を最初から設計に織り込むと、

  • 文字起こし → OCR
  • 構造抽出・意味づけ → VLM
  • 整形・要約 → LLM(テキスト専用)

という 役割分担ベースのアーキテクチャ が自然に見えてきます。

今回の実装は、この中の 「構造抽出+軽い整形」の部分だけを、あえてVLMに任せている 形です。
この “責務の切り方” は、今後のマルチモーダルシステム設計でもほぼそのまま使えると思います。

8.2 実務ツールとしてのリアリティ

■ 「これ1本で全てのドキュメント解析が解決する」は幻想

今回のWeb UIは、体験としてはかなり気持ちいいです。

  • 画像をドラッグ&ドロップするだけ
  • Markdownで構造化されたテキストが返ってくる
  • コピー&ペーストでノートアプリやVSCodeに即貼れる

しかし、そのまま業務システムに入れるにはギャップもあると感じました。

例えば:

  • 一貫したフォーマットが求められる帳票類
  • 数値・単位の1つの誤りも許されない解析
  • 数式・表の厳密な変換が必要な技術資料

こういった場面では、
VLM単体に“正解”を任せるのは危険です。

現実的には:

  • VLMでたたき台のMarkdownを生成
  • 人間 or LLMで後段整形(レビュー)
  • 必要ならOCRや構造化ライブラリで再チェック

という “半自動化+人間の検査ループ” が落とし所になりそうです。

■ ローカルであるがゆえの「運用フリクション」

ローカルVLMはクラウドに比べて自由度が高い一方で、
運用する人間のスキルにかなり依存する ことも体感しました。

  • GPUが使えているのか / CPUに落ちているのか
  • ドライバ・CUDA・VRAMなどの環境要因
  • モデルサイズと処理時間のトレードオフを自分で握る必要がある

「APIキーを発行して、URLだけ設定しておけばOK」というクラウドに比べると、
ローカルは“強いけど扱いにクセのある相棒” という印象です。

ここをどう抽象化して、
非MLエンジニアにも扱える形にパッケージングするか が、
今後のプロダクト設計のポイントになりそうだと感じています。

9. まとめ

Ollama×FastAPI×LLaVA を組み合わせることで、

  • クラウド不要
  • セキュア
  • コストゼロ
  • スキャン画像→Markdown構造化
  • 実務PoC・研究用途で高い適性

という“ローカルVLM文書解析ツール”を構築できました。

特に、

「ローカルでVLMがここまでできる」という体験そのものが大きな価値

です。


執筆:宮脇 彰梧(ルミナイ株式会社 / Lluminai)

参考コード: GitHub

※本記事で使用している画像は、すべてフリー素材サイト PexelsSlidesgoより取得しています。


【現在採用強化中です!】

  • AIエンジニア
  • PM/PdM
  • 戦略投資コンサルタント

▼代表とのカジュアル面談URL
https://pitta.me/matches/VCmKMuMvfBEk

ルミナイ - 産業データをLLM Readyにするための技術ブログ

Discussion