- 2026-10-12 公開の MiniCPM-V 4.7 の小型版(1B、Apache-2.0)を、Apple M3 の Mac で transformers から動かした。メモリは 4.4GB ほどで、API キーは不要
- 自作の日本語レシート 1 種類を各モード 1 回ずつ読ませた。くっきりした画像なら 1 文字も間違えずに書き出せた。半分に縮めてぼかした画像では 16x モードで誤りが出たが(文字誤り率 5.4%)、4x モードにすると 0% になった
- 逆に、くっきりした画像では 4x のほうが 1 文字間違え、遅くもなった。試行が少ないので、どちらのモードを使うかは自分の画像で確かめる必要がある
背景
MiniCPM-V は、OpenBMB が公開している画像理解モデルのシリーズです。スマートフォンなどの端末で動かすことを重視しています。2026-10-12 に MiniCPM-V 4.7 が公開され、35B-A3B(MoE)と 1B の 2 つのサイズが出ました(GitHub の News)。
今回試すのは小さいほうの MiniCPM-V-4.7-1B です。モデルカードによると、次の特徴があります。
| 項目 | 内容 |
|---|---|
| 構成 | 画像エンコーダー(SigLIP2 ベース)+ 言語モデル(Qwen3.5 / Qwen3.6 ベース) |
| できること | 画像 1 枚・複数枚の対話、動画の理解、OCR、PDF の読み取り、位置の特定(grounding) |
| ライセンス | Apache-2.0 |
| 画像トークンの圧縮 |
downsample_mode で 16x(既定)と 4x(細かい部分向け)を選べる |
画像トークンは、画像をモデルが読める単位に分けたものです。16x は画像を強く圧縮してトークンを少なくし、4x は圧縮を弱めて細部を残します。モデルカードでは、4x は「細かい部分向け」とされています。
「文字が細かい画像では 4x のほうが本当に読めるのか」を、正解がわかっている日本語のレシートで確かめました。
試してみる
やること
- 写真(金門橋)を日本語で説明させる
- 日本語のレシートを OCR させ、16x と 4x で比べる。レシートは次の 2 枚
-
receipt.png: くっきりした版(560×540) -
receipt_small.jpg: 半分に縮め、少しぼかし、画質を落とした JPEG(280×270)
-
- レシートの内容について質問する
レシートは、正解の文字列がわかるように PIL(Python の画像ライブラリ)で作りました。精度は 文字誤り率(CER。正解にするために必要な文字の追加・削除・置換の数 ÷ 正解の文字数)で測ります。空白と改行は除いて比べます。
動作環境
- OS: macOS 26.6.2(Apple M3、メモリ 24GB)
- Python: 3.12.15
- 主要ライブラリ: transformers==5.20.1, torch==2.14.1, torchvision==0.29.1, av==19.0.1, accelerate==1.15.0(間接依存も含めて
requirements.txtで固定) - 実行デバイス: MPS(Apple GPU)、bfloat16
- 実行日: 2026-10-18
セットアップ
cd code
uv venv --python 3.12 .venv
uv pip install --python .venv -r requirements.txt
./prepare_media.sh # レシート画像を作り、金門橋の写真をダウンロードする
./run.sh # 初回はモデル(約 3.1GB)を Hugging Face から自動でダウンロードする
API キーは不要です。モデルは gated(利用申請が必要なモデル)ではありません。
モデルカードのインストール手順では torchcodec(動画などの読み込みライブラリ)を入れます。torchcodec がうまく入らない環境向けに、代わりに PyAV(av)を使う方法も書かれています(モデルカード)。今回は画像しか扱わないので、PyAV を使う構成にしました。
コード
レシート画像を作るコードです。
"""OCR の検証用に、正解のテキストがわかっている日本語のレシート画像を作る(macOS のヒラギノ角ゴシックを使う)。
receipt.png(くっきりした版)と receipt_small.jpg(縮小・ぼかし・低画質の版)の 2 枚を作る。
"""
from pathlib import Path
from PIL import Image, ImageDraw, ImageFilter, ImageFont
FONT = "/System/Library/Fonts/ヒラギノ角ゴシック W3.ttc"
# (左に書く文字, 右寄せで書く金額)
LINES = [
("ブルーベリー珈琲 渋谷店", ""),
("2026年10月18日(土) 14:32", ""),
("カフェラテ (M)", "¥520"),
("抹茶チーズケーキ", "¥480"),
("季節のフルーツサンド", "¥650"),
("小計", "¥1,650"),
("消費税(8%)", "¥132"),
("合計", "¥1,782"),
("お預かり", "¥2,000"),
("お釣り", "¥218"),
]
def main() -> None:
out = Path(__file__).parent / "media"
out.mkdir(exist_ok=True)
font = ImageFont.truetype(FONT, 28)
img = Image.new("RGB", (560, 60 + 48 * len(LINES)), "white")
draw = ImageDraw.Draw(img)
for i, (left, right) in enumerate(LINES):
y = 30 + 48 * i
draw.text((30, y), left, font=font, fill="black")
if right:
draw.text((530, y), right, font=font, fill="black", anchor="ra")
img.save(out / "receipt.png")
# 難しめの版: 半分に縮小し、少しぼかして、画質の低い JPEG で保存する
small = img.resize((img.width // 2, img.height // 2), Image.LANCZOS).filter(ImageFilter.GaussianBlur(0.6))
small.save(out / "receipt_small.jpg", quality=40)
truth = "\n".join(f"{left} {right}".strip() for left, right in LINES)
(out / "receipt_truth.txt").write_text(truth + "\n")
print(f"saved: media/receipt.png ({img.width}x{img.height}), media/receipt_small.jpg ({small.width}x{small.height})")
if __name__ == "__main__":
main()
推論のコードです。モデルカードの「Inference with Transformers」の例を元にしています。違いは、再現しやすいように 貪欲法(毎回いちばん確率の高いトークンを選ぶ方法。do_sample=False)にした点です。モデルカードの推奨は、temperature=0.7 などのサンプリングです。
"""MiniCPM-V-4.7-1B を Mac(Apple Silicon / MPS)で動かし、画像の説明と日本語 OCR を試す。
画像は prepare_media.sh で media/ に用意する。
推論のコードは、モデルカードの「Inference with Transformers」の例を元にした:
https://huggingface.co/openbmb/MiniCPM-V-4.7-1B
"""
import platform
import time
from importlib.metadata import version
from pathlib import Path
import torch
from transformers import AutoModelForImageTextToText, AutoProcessor
MODEL_ID = "openbmb/MiniCPM-V-4.7-1B"
MEDIA = Path(__file__).parent / "media"
def ask(model, processor, image: str, prompt: str, mode: str) -> dict:
"""画像 1 枚と質問を渡して答えを生成する。mode は "16x"(既定)か "4x"(細かい部分向け)。"""
conversation = [
{
"role": "user",
"content": [
{"type": "image", "path": str(MEDIA / image)},
{"type": "text", "text": prompt},
],
}
]
batch = processor.apply_chat_template(
conversation,
tokenize=True,
add_generation_prompt=True,
return_dict=True,
return_tensors="pt",
downsample_mode=mode,
max_slice_nums=36,
enable_thinking=False,
).to(model.device)
t0 = time.perf_counter()
# 再現しやすいように貪欲法(do_sample=False)にする。モデルカードの推奨はサンプリング(temperature=0.7 など)
output_ids = model.generate(
**batch,
downsample_mode=mode, # apply_chat_template と同じ値にする
max_new_tokens=512,
do_sample=False,
repetition_penalty=1.03,
)
elapsed = time.perf_counter() - t0
new_tokens = output_ids[0][batch.input_ids.shape[1]:]
text = processor.decode(new_tokens, skip_special_tokens=True, clean_up_tokenization_spaces=False)
return {
"text": text.strip(),
"input_tokens": batch.input_ids.shape[1],
"output_tokens": len(new_tokens),
"seconds": elapsed,
}
def cer(pred: str, truth: str) -> float:
"""文字誤り率(空白と改行を除いて比べる)= 編集距離 ÷ 正解の文字数。"""
a = "".join(pred.split())
b = "".join(truth.split())
prev = list(range(len(b) + 1))
for i, ca in enumerate(a, 1):
cur = [i]
for j, cb in enumerate(b, 1):
cur.append(min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (ca != cb)))
prev = cur
return prev[-1] / len(b)
def show(title: str, r: dict) -> None:
print(f"\n--- {title} ---")
print(r["text"])
print(
f"[入力 {r['input_tokens']} tok / 出力 {r['output_tokens']} tok / {r['seconds']:.1f} 秒"
f" / {r['output_tokens'] / r['seconds']:.1f} tok/s]"
)
def main() -> None:
print(f"Python {platform.python_version()} / {platform.system()} {platform.machine()}")
for pkg in ["transformers", "torch", "torchvision", "av", "accelerate"]:
print(f"{pkg}=={version(pkg)}")
t0 = time.perf_counter()
processor = AutoProcessor.from_pretrained(MODEL_ID)
model = AutoModelForImageTextToText.from_pretrained(MODEL_ID, torch_dtype="auto", device_map="auto")
n_params = sum(p.numel() for p in model.parameters())
print(f"\nモデル読み込み: {time.perf_counter() - t0:.1f} 秒 / device={model.device} / dtype={model.dtype}")
print(f"パラメータ数: {n_params / 1e9:.2f}B")
# 最初の 1 回は準備に時間がかかるので、計測の前に短い生成を 1 回しておく
ask(model, processor, "receipt.png", "この画像は何ですか?一言で。", "16x")
print("\n== 1. 写真の説明 ==")
show("GoldenGate.png(16x)", ask(model, processor, "GoldenGate.png", "この写真を日本語で 3 文で説明してください。", "16x"))
print("\n== 2. 日本語レシートの OCR ==")
truth = (MEDIA / "receipt_truth.txt").read_text()
ocr_prompt = "画像の文字をすべて、行ごとにそのまま書き出してください。説明は不要です。"
for image in ["receipt.png", "receipt_small.jpg"]:
for mode in ["16x", "4x"]:
r = ask(model, processor, image, ocr_prompt, mode)
show(f"{image}({mode})", r)
print(f"文字誤り率(CER): {cer(r['text'], truth):.1%}")
print("\n== 3. レシートについての質問 ==")
show(
"receipt.png(16x)",
ask(model, processor, "receipt.png", "一番高い商品の名前と値段、お釣りの金額を答えてください。", "16x"),
)
if torch.backends.mps.is_available():
print(f"\nMPS の確保メモリ: {torch.mps.driver_allocated_memory() / 1e9:.1f} GB")
if __name__ == "__main__":
main()
実行結果
outputs/run.log から貼ります。ライブラリの警告([transformers] で始まる行など)は省きました。主な警告の中身は後述します。
Python 3.12.15 / Darwin arm64
transformers==5.20.1
torch==2.14.1
torchvision==0.29.1
av==19.0.1
accelerate==1.15.0
モデル読み込み: 2.9 秒 / device=mps:0 / dtype=torch.bfloat16
パラメータ数: 1.55B
== 1. 写真の説明 ==
--- GoldenGate.png(16x) ---
赤い橋が湾を通る。
水面に静かな波が浮かぶ。
山々と空が背景にそびえ立つ。
[入力 359 tok / 出力 33 tok / 2.3 秒 / 14.2 tok/s]
== 2. 日本語レシートの OCR ==
--- receipt.png(16x) ---
ブルーベリー珈琲 渋谷店
2026年10月18日(土) 14:32
カフェラテ (M) ¥520
抹茶チーズケーキ ¥480
季節のフルーツサンド ¥650
小計 ¥1,650
消費税(8%) ¥132
合計 ¥1,782
お預かり ¥2,000
お釣り ¥218
[入力 236 tok / 出力 100 tok / 3.4 秒 / 29.0 tok/s]
文字誤り率(CER): 0.0%
--- receipt.png(4x) ---
ブルーベリー珈班 渋谷店
2026年10月18日(土) 14:32
カフェラテ (M)
¥520
抹茶チーズケーキ
¥480
季節のフルーツサンド
¥650
小計
¥1,650
消費税(8%)
¥132
合計
¥1,782
お預かり
¥2,000
お釣り
¥218
[入力 824 tok / 出力 109 tok / 4.9 秒 / 22.2 tok/s]
文字誤り率(CER): 0.9%
--- receipt_small.jpg(16x) ---
ブルーベリー珈琲 渋谷店
2026年10月18日(土) 14:32
カフェラテ(M) ¥520
抹茶チーサーキ ¥480
季節の Fruルサンド ¥650
小計 ¥1,650
消費税(8%) ¥132
合計 ¥1,782
お預かり ¥2,000
お釣り ¥218
[入力 100 tok / 出力 103 tok / 3.2 秒 / 31.9 tok/s]
文字誤り率(CER): 5.4%
--- receipt_small.jpg(4x) ---
ブルーベリー珈琲 渋谷店
2026年10月18日(土) 14:32
カフェラテ (M) ¥520
抹茶チーズケーキ ¥480
季節のフルーツサンド ¥650
小計 ¥1,650
消費税(8%) ¥132
合計 ¥1,782
お預かり ¥2,000
お釣り ¥218
[入力 292 tok / 出力 100 tok / 3.3 秒 / 30.6 tok/s]
文字誤り率(CER): 0.0%
== 3. レシートについての質問 ==
--- receipt.png(16x) ---
一番高い商品の名前は「季節のフルーツサンド」で、値段は¥650です。
お釣りの金額は¥218です。
[入力 234 tok / 出力 34 tok / 1.8 秒 / 18.9 tok/s]
MPS の確保メモリ: 4.4 GB
結果を読む
OCR:今回のくっきりした画像は、16x で全文正解
| 画像 | モード | 入力トークン | 文字誤り率 | 生成時間 |
|---|---|---|---|---|
| receipt.png(560×540) | 16x | 236 | 0.0% | 3.4 秒 |
| receipt.png(560×540) | 4x | 824 | 0.9% | 4.9 秒 |
| receipt_small.jpg(280×270) | 16x | 100 | 5.4% | 3.2 秒 |
| receipt_small.jpg(280×270) | 4x | 292 | 0.0% | 3.3 秒 |
くっきりしたレシートは、16x で店名・日時・金額まですべて正しく読めました。今回の 1 枚では、1B という小さいモデルでも、きれいな日本語の印字を正しく読めました。実際の書類やレシートの写真でも同じように読めるかは、確かめる必要があります。
今回は、小さい文字が 4x で直った
縮小・ぼかしをかけたレシートでは、16x で「抹茶チーズケーキ」が「抹茶チーサーキ」に、「季節のフルーツサンド」が「季節の Fruルサンド」になりました。4x にすると、どちらも正しく読めて誤りは 0% になりました。入力トークンは 100 から 292 に増えましたが、生成時間はほぼ同じでした(3.2 秒と 3.3 秒)。
画像が小さいと、16x では 1 文字あたりに使える情報が足りなくなるのだと思われます。4x は圧縮が弱いので、細かい字形が残ります。
今回のくっきりした画像では、4x が逆効果だった
一方、くっきりしたレシートを 4x で読ませると、「珈琲」が「珈班」になりました。さらに、金額が商品名と別の行に分かれて出力されました(文字誤り率は空白と改行を除いて測っているので、この分かれ方は数字に入っていません)。入力トークンが 3.5 倍(236 → 824)になり、生成時間も 1.5 秒ほど延びました。
1 回ずつ、1 種類のレシートで試しただけなので、一般化はできません。今回の結果からは「まず 16x で読み、文字が小さい画像だけ 4x にする」という使い分けが考えられますが、実際に使うときは、自分の画像で両方のモードを比べて確かめる必要があります。
写真の説明と質問への回答
金門橋の写真は「赤い橋が湾を通る」など、3 文で説明できました。ただし、「金門橋」や「サンフランシスコ」という固有名詞は出てきませんでした。
レシートについての質問(一番高い商品とお釣り)には、「季節のフルーツサンド」「¥650」「¥218」と、正しく答えました。
速さとメモリ
今回の 1 回の実行では、M3 の Mac で、生成は 1 秒あたり 14〜32 トークンほどでした。速さは環境や入力で変わるので、実際に使う前に自分の環境で測る必要があります。MPS の確保メモリは 4.4GB です。モデルの読み込みは 2.9 秒でした(ダウンロード済みのモデルを読んだときの値です)。
ログには、causal_conv1d や flash-linear-attention がないので、遅い参照実装(PyTorch だけで書いた版)を使う、という警告が出ていました。これらの高速版が入れば、もっと速くなる可能性があります(今回は試していません)。ほかに、画像の読み込みに torchvision を使うのは非推奨で、torchcodec を入れるように、という警告も出ていました。
なお、パラメータ数を数えると 1.55B でした。名前は「1B」ですが、Hugging Face のページの表示は「2B params」です。
ログに出る警告
downsample_mode などを apply_chat_template に渡すと、「processor_kwargs に入れるように」という警告が出ます。ただし、値は効いていました。max_slice_nums を 1 と 36 にすると、金門橋の写真の入力トークンが 83 と 348 に変わりました(code/check_kwargs.py、ログは outputs/check_kwargs.log)。今回は、モデルカードの書き方のままにしています。
downsample_mode=16x max_slice_nums=1 -> 入力 83 tok
downsample_mode=16x max_slice_nums=36 -> 入力 348 tok
downsample_mode=4x max_slice_nums=1 -> 入力 275 tok
downsample_mode=4x max_slice_nums=36 -> 入力 1308 tok
まとめ
- MiniCPM-V-4.7-1B は、M3 の Mac でもメモリ 4.4GB ほどで動いた。API キーも不要
- 今回のくっきりした日本語のレシートは、16x モードで全文正しく読めた
- 小さくぼやけた画像では 16x で誤りが出たが、4x にすると直った。ただし、くっきりした画像では 4x が逆効果だった
- どれもレシート 1 種類・各 1 回の結果。実際に使うときは、自分の画像で精度と速度を確かめる必要がある
参考
公式ドキュメント・一次情報
- openbmb/MiniCPM-V-4.7-1B(モデルカード) — OpenBMB / Hugging Face
- OpenBMB/MiniCPM-V(GitHub) — OpenBMB(News に 2026-10-12 公開と記載)
- MiniCPM-V 4.7 — OpenBMB
-
google-gemma/cookbook — Google(金門橋の写真
apps/sample-data/GoldenGate.pngの出典。Apache-2.0)
参考にした記事
- なし

