はじめに
本記事では、Apple Silicon搭載Mac上で mlx-community/Qwen3-VL-8B-Instruct-4bit を動かし、画像入力に対応したローカルAPIサーバとしてホストする手順を解説します。
最終的には、次のような処理をローカルで実行できる状態を目指します。
- UIスクリーンショットから日本語テキストを読み取る
-
SU、フロア、キャラクター名、セリフ、ステータスを構造化する - 下部サムネイル内の重複テキストを無視する
- OCR結果をJSON Schemaに沿って返す
- OpenAI互換の
/v1/chat/completionsAPIとして利用する
構成は次のとおりです。
OCR対象画像
↓
MLX-VLM FastAPI Server
↓
Qwen3-VL-8B-Instruct 4bit
↓
JSON Schemaで制約されたOCR結果
本記事は2026年8月時点の情報を基にしています。MLX、mlx-vlm、Transformers周辺は更新が速いため、再現環境ではインストール後のバージョンを記録してください。
背景と課題
macOS上でVLMをローカルホストする場合、PyTorch + MPSやGGUF + llama.cppも候補になります。
一方、Apple Siliconでは、Appleが開発するMLフレームワークである MLX を利用すると、Unified Memoryを活かした推論環境を比較的シンプルに構築できます。
今回利用するモデルは次の2つの要素から構成されます。
| 要素 | 内容 |
|---|---|
| 元モデル | Qwen/Qwen3-VL-8B-Instruct |
| MLX変換版 | mlx-community/Qwen3-VL-8B-Instruct-4bit |
| 量子化 | 4bit |
| モデルファイル容量 | 約5.76 GB |
| ライセンス | Apache-2.0 |
| 推論ランタイム | mlx-vlm |
| API | OpenAI互換Chat Completions API |
今回の対象は、一般的な文書OCRよりも難しい条件を含みます。
- 日本語の小さいUI文字
- 装飾背景上の文字
- 複数カラム
- サムネイル内の重複文字
- OCRと同時にフィールドの意味を解釈する必要がある
- 結果を後段処理しやすいJSONに固定したい
そのため、単純な文字認識だけでなく、画像理解とKey Information Extractionを同時に行えるVLMを利用します。
動作条件
MLXの公式要件は次のとおりです。
- Apple Silicon搭載Mac
- macOS 14.0以降
- ARMネイティブのPython 3.10以降
本記事では、Python 3.12を利用します。
メモリの目安
以下は公式要件ではなく、ローカル実行時の実務的な目安です。
| Unified Memory | 目安 |
|---|---|
| 8 GB | 推奨しない |
| 16 GB | 4bit版のPoC候補。ほかのアプリを終了して実行 |
| 24 GB | 4bit版の常用候補 |
| 32 GB以上 | 複数プロセスや上位量子化との比較がしやすい |
モデルファイルが約5.76 GBでも、実行時にはVision Encoder、KV Cache、画像テンソル、Pythonプロセスなどのメモリが追加で必要です。
エラーログ
ここでは、環境構築時に遭遇しやすい代表的な失敗例を先に整理します。
mlxをインストールできない
ERROR: Could not find a version that satisfies the requirement mlx
ERROR: No matching distribution found for mlx
主な原因は次のいずれかです。
- Intel Macを利用している
- Rosetta経由のx86_64 Pythonを利用している
- macOSが14未満
- Pythonが3.10未満
確認コマンドは次のとおりです。
uname -m
sw_vers -productVersion
python3 --version
python3 -c "import platform; print(platform.machine(), platform.processor())"
期待値の例です。
arm64
14.x 以降
Python 3.10 以降
arm64 arm
uname -m が x86_64 の場合は、ターミナルやPythonがRosetta環境で動作していないか確認してください。
UI用extraのインストールでzshに怒られる
zsh: no matches found: mlx-vlm[ui]
zshが角括弧をglobとして解釈していることが原因です。パッケージ名をクォートします。
pip install -U 'mlx-vlm[ui]'
本記事のAPIサーバ構築だけなら、[ui] は不要です。
画像ファイルが見つからない
FileNotFoundError: [Errno 2] No such file or directory: 'input.png'
APIリクエストで指定した画像パスは、APIサーバを実行しているMacから見えるパスとして解決されます。
相対パスではなく絶対パスを使用します。
realpath input.png
macOS環境によって realpath が利用できない場合は、次でも確認できます。
python3 -c "from pathlib import Path; print(Path('input.png').resolve())"
初回リクエストが長時間止まって見える
初回起動時は、Hugging Face Hubからモデルがダウンロードされます。さらに、モデルロードとMetal側の初期化が発生するため、2回目以降より時間がかかります。
キャッシュの使用量は次のコマンドで確認できます。
du -sh ~/.cache/huggingface/hub 2>/dev/null
別ターミナルからヘルスチェックも行います。
curl http://127.0.0.1:8080/health
メモリ不足でプロセスが終了する
典型的には、次のような状態になります。
Killed: 9
またはmacOSのメモリプレッシャーが上昇し、極端にレスポンスが遅くなります。
対策は次の順序で行います。
- 4bit版を利用する
- Chrome、After Effects、Docker Desktopなどメモリ消費の大きいアプリを終了する
- 入力画像をカード単位にクロップする
-
max_tokensを必要最小限にする - 同時リクエスト数を減らす
OCR用途では、長い回答を生成する必要がないため、max_tokens=256〜512程度から始めるのが妥当です。
解決方法
1. Apple SiliconとPython環境を確認する
uname -m
sw_vers -productVersion
python3 --version
本記事では、次のような環境を前提とします。
arm64
macOS 14.0以降
Python 3.12.x
Python 3.12が未導入の場合は、Homebrewなどで導入します。
brew install python@3.12
インストール後に確認します。
python3.12 --version
2. 作業ディレクトリを作る
mkdir -p ~/work/qwen3-vl-mlx-ocr
cd ~/work/qwen3-vl-mlx-ocr
ディレクトリ構成は次のようにします。
qwen3-vl-mlx-ocr/
├── .venv/
├── input.png
├── request.json
└── ocr_client.py
OCR対象画像を input.png として配置してください。
3. Python仮想環境を作る
python3.12 -m venv .venv
source .venv/bin/activate
pip関連を更新します。
python -m pip install -U pip setuptools wheel
仮想環境が有効か確認します。
which python
python --version
期待されるパスです。
~/work/qwen3-vl-mlx-ocr/.venv/bin/python
4. mlx-vlmをインストールする
pip install -U mlx-vlm
インストール結果を記録します。
python -c "import mlx; print('mlx:', mlx.__version__)"
pip show mlx-vlm
再現性を残す場合は、環境構築後に依存関係を保存します。
pip freeze > requirements.lock.txt
mlx-vlmは更新頻度が高いため、検証が完了した環境ではバージョンを固定することを推奨します。
5. CLIで画像入力を試す
APIサーバを起動する前に、CLIでモデルと画像入力が正常に動作するか確認します。
python -m mlx_vlm.generate \
--model mlx-community/Qwen3-VL-8B-Instruct-4bit \
--image "$(pwd)/input.png" \
--prompt "画像内に表示されている日本語テキストを読み取り、簡潔に列挙してください。" \
--temperature 0.0 \
--max-tokens 256
初回実行時はモデルが自動的にダウンロードされます。
ポイント解説
-
--model
Hugging Face上のMLX変換済みモデルIDです。 -
--image
OCR対象画像です。絶対パスを推奨します。 -
--temperature 0.0
OCRの再現性を高め、創作的な補完を抑えます。 -
--max-tokens
OCR結果に必要な出力長だけを確保します。
6. APIサーバとして起動する
ローカルMacからのみ利用する構成です。
mlx_vlm.server \
--model mlx-community/Qwen3-VL-8B-Instruct-4bit \
--host 127.0.0.1 \
--port 8080
なぜ127.0.0.1を指定するのか
mlx-vlmサーバのデフォルトホストは 0.0.0.0 です。
0.0.0.0で起動すると、ファイアウォールやネットワーク設定によってはLAN上のほかの端末からアクセス可能になります。ローカル利用だけなら、明示的に 127.0.0.1 を指定します。
サーバ起動後、別のターミナルを開いて仮想環境を有効にします。
cd ~/work/qwen3-vl-mlx-ocr
source .venv/bin/activate
ヘルスチェックを実行します。
curl http://127.0.0.1:8080/health | python -m json.tool
モデル一覧も確認します。
curl http://127.0.0.1:8080/v1/models | python -m json.tool
7. OpenAI互換APIで画像OCRを実行する
画像の絶対パスを環境変数に入れます。
IMAGE_PATH="$(python -c "from pathlib import Path; print(Path('input.png').resolve())")"
echo "${IMAGE_PATH}"
リクエストJSONを作成します。
cat > request.json <<JSON
{
"model": "mlx-community/Qwen3-VL-8B-Instruct-4bit",
"messages": [
{
"role": "system",
"content": "あなたは画像内の日本語UIテキストを正確に抽出するOCRエージェントです。推測や補完を避けてください。"
},
{
"role": "user",
"content": [
{
"type": "text",
"text": "画像内の各カードについて、上部に表示された情報だけを抽出してください。下部サムネイル内の文字は無視してください。SU、floor、character、dialogue、statusを返してください。判読不能な値はnullにしてください。"
},
{
"type": "image_url",
"image_url": {
"url": "${IMAGE_PATH}"
}
}
]
}
],
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "ocr_cards",
"strict": true,
"schema": {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"su": {
"type": ["string", "null"]
},
"floor": {
"type": ["integer", "null"]
},
"character": {
"type": ["string", "null"]
},
"dialogue": {
"type": ["string", "null"]
},
"status": {
"type": ["string", "null"]
}
},
"required": [
"su",
"floor",
"character",
"dialogue",
"status"
],
"additionalProperties": false
}
}
},
"required": ["items"],
"additionalProperties": false
}
}
},
"temperature": 0.0,
"max_tokens": 512,
"stream": false
}
JSON
APIを呼び出します。
curl -sS \
-X POST http://127.0.0.1:8080/v1/chat/completions \
-H "Content-Type: application/json" \
--data-binary @request.json \
| python -m json.tool
レスポンス例です。
{
"items": [
{
"su": "SU1",
"floor": 80,
"character": "八幡",
"dialogue": "他校と合同イベントねぇ...",
"status": "OK"
},
{
"su": "SU2",
"floor": 180,
"character": "雪乃",
"dialogue": "一色さんの初仕事ね",
"status": "OK"
}
]
}
実際のChat Completions APIレスポンスでは、上記JSON文字列が choices[0].message.content に格納されます。
8. Pythonクライアントから呼び出す
requestsを追加します。
pip install -U requests
ocr_client.pyを作成します。
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
import requests
API_URL = "http://127.0.0.1:8080/v1/chat/completions"
MODEL_ID = "mlx-community/Qwen3-VL-8B-Instruct-4bit"
IMAGE_PATH = Path("input.png").resolve()
def build_payload(image_path: Path) -> dict[str, Any]:
"""OCR用のOpenAI互換リクエストを構築します。"""
if not image_path.is_file():
raise FileNotFoundError(f"画像が見つかりません: {image_path}")
return {
"model": MODEL_ID,
"messages": [
{
"role": "system",
"content": (
"あなたは日本語UI画像を正確に解析するOCRエージェントです。"
"画像から確認できない文字を推測しないでください。"
),
},
{
"role": "user",
"content": [
{
"type": "text",
"text": (
"画像内の各カードについて、上部のテキスト情報だけを抽出してください。"
"下部サムネイル内の文字は無視してください。"
),
},
{
"type": "image_url",
"image_url": {"url": str(image_path)},
},
],
},
],
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "ocr_cards",
"strict": True,
"schema": {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"su": {"type": ["string", "null"]},
"floor": {"type": ["integer", "null"]},
"character": {"type": ["string", "null"]},
"dialogue": {"type": ["string", "null"]},
"status": {"type": ["string", "null"]},
},
"required": [
"su",
"floor",
"character",
"dialogue",
"status",
],
"additionalProperties": False,
},
}
},
"required": ["items"],
"additionalProperties": False,
},
},
},
"temperature": 0.0,
"max_tokens": 512,
"stream": False,
}
def main() -> None:
payload = build_payload(IMAGE_PATH)
response = requests.post(
API_URL,
json=payload,
timeout=300,
)
response.raise_for_status()
response_body = response.json()
content = response_body["choices"][0]["message"]["content"]
ocr_result = json.loads(content)
print(json.dumps(ocr_result, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
実行します。
python ocr_client.py
ポイント解説
json_schemaを利用すると、自然言語の説明文やMarkdownコードフェンスが混入しにくくなります。
ただし、構造が正しいことと、OCR文字列が正しいことは別問題です。
評価時は、少なくとも次の指標を分けて計測してください。
- JSON Schema準拠率
- フィールド完全一致率
- Character Error Rate
- カード間の対応誤り率
- サムネイル文字の混入率
-
null率 - 1画像あたりの処理時間
補足Tips
固定レイアウトなら画像全体を渡さない
対象UIのレイアウトが固定されている場合、VLMにレイアウト分割まで任せるより、事前にカード単位でクロップする方が安定します。
全体スクリーンショット
↓
固定座標またはOpenCVでカード分割
↓
カードごとにQwen3-VLへ入力
↓
結果を結合
モデルサイズを大きくするより、入力画像のROI設計を改善した方がOCR精度に効くケースがあります。
小さい文字は2倍程度に拡大する
Pillowを使った単純な拡大例です。
pip install -U pillow
from pathlib import Path
from PIL import Image, ImageEnhance
INPUT_PATH = Path("input.png")
OUTPUT_PATH = Path("input_scaled.png")
SCALE = 2
with Image.open(INPUT_PATH) as image:
enlarged = image.resize(
(image.width * SCALE, image.height * SCALE),
Image.Resampling.LANCZOS,
)
enhanced = ImageEnhance.Contrast(enlarged).enhance(1.15)
enhanced.save(OUTPUT_PATH)
print(OUTPUT_PATH.resolve())
ただし、過度なシャープ化や二値化は、濁点、半濁点、細い句読点を破壊する可能性があります。前処理なし、2倍拡大、コントラスト補正の3条件程度を比較してください。
OCRプロンプトでは「読めない場合の挙動」を固定する
VLMは文脈から自然に補完する能力を持つため、OCRではそれが誤りになります。
プロンプトには次を明記します。
画像から確認できない文字を推測しないでください。
判読できないフィールドはnullにしてください。
下部サムネイル内の文字は無視してください。
原文の表記、句読点、三点リーダーを可能な限り保持してください。
特に、人名や既知作品のセリフは、モデルが知識から補完する可能性があります。
外部PCから呼ぶ場合はローカル画像パスを渡さない
次の指定は、サーバとクライアントが同じMac上にある場合に有効です。
{
"type": "image_url",
"image_url": {
"url": "/Users/{user}/work/qwen3-vl-mlx-ocr/input.png"
}
}
別PCからAPIを呼ぶ場合、そのパスはクライアント側ではなく、サーバ側で解決されます。
外部クライアント対応時は、次のいずれかを設計してください。
- サーバから取得可能なHTTP URLを渡す
- アップロードAPIを別途用意する
- 対応状況を確認した上でData URLを利用する
- 共有ストレージ上のサーバ側パスへ変換する
LAN公開時は認証を設定する
LAN内の別端末からアクセスする場合は、次のように起動できます。
mlx_vlm.server \
--model mlx-community/Qwen3-VL-8B-Instruct-4bit \
--host 0.0.0.0 \
--port 8080 \
--api-key "{LOCAL_API_KEY}"
APIキーをリクエストヘッダーに指定します。
curl \
-H "Authorization: Bearer {LOCAL_API_KEY}" \
http://{MAC_IP_ADDRESS}:8080/v1/models
社外ネットワークへ直接公開する構成は避け、必要に応じてVPN、リバースプロキシ、TLS、IP制限を追加してください。
サーバをバックグラウンド常駐させる
一時的な検証では、ターミナルを開いたままで問題ありません。
常用する場合は、macOSの launchd で次を管理すると安定します。
- ログイン時の自動起動
- 異常終了時の再起動
- 標準出力と標準エラーの保存
- 仮想環境内の実行ファイルを絶対パスで指定
ただし、モデルロードには大きなメモリを使用します。常駐化する前に、After Effectsなどの通常業務とのメモリ競合を確認してください。
4bitと8bitを同じ評価セットで比較する
4bit版はローカルホストしやすい一方、小さい日本語文字では量子化誤差の影響が出る可能性があります。
比較時は、目視ではなく同じ正解データを使って評価します。
評価画像
├── 通常サイズ
├── 2倍拡大
└── カード単位クロップ
モデル
├── 4bit
└── 8bit
指標
├── CER
├── 完全一致率
├── レイアウト対応誤り
└── レイテンシ
OCRでは、モデル量子化よりもクロップ方法や入力解像度が支配的になることもあります。
専用OCRとのハイブリッド構成も検討する
大量処理やBounding Boxが必要な場合、VLM単体より次の構成が適しています。
画像
↓
PP-OCRなどの専用OCR
├── テキスト
├── Bounding Box
└── confidence
↓
Qwen3-VLまたはLLM
├── フィールド割当
├── 重複除去
└── JSON正規化
VLMは意味理解に強い一方、文字単位の信頼度やBounding Boxを厳密に扱う用途では専用OCRに利点があります。
まとめ
本記事では、Apple Silicon搭載Mac上で mlx-community/Qwen3-VL-8B-Instruct-4bit をホストし、画像OCRをOpenAI互換APIとして呼び出すところまで構築しました。
重要なポイントは次のとおりです。
- MLXはApple Silicon、macOS 14以降、ARMネイティブPython 3.10以降が前提
-
mlx-vlmを使うとQwen3-VLのMLX版をCLIおよびAPIサーバで実行できる - ローカル利用では
--host 127.0.0.1を明示する - OCR結果はStructured OutputsのJSON Schemaで固定する
- 文字列の幻覚を防ぐため、推測禁止と
null条件をプロンプトに入れる - 固定UIではカード単位のクロップが精度、速度、再現性に効く
- 量子化方式だけでなく、入力解像度とROI設計を同一データセットで比較する
まずは4bit版でPoCを作り、実データ100〜300枚程度でCERとフィールド完全一致率を測定すると、実運用に必要な前処理とモデルサイズを判断しやすくなります。