0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

MiniCPM-V-4.7-1B を Mac で動かし、日本語レシートの OCR を試した:今回は小さい文字が 4x モードで直った

0
Last updated at Posted at 2026-10-21
  • 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 のほうが本当に読めるのか」を、正解がわかっている日本語のレシートで確かめました。

試してみる

やること

  1. 写真(金門橋)を日本語で説明させる
  2. 日本語のレシートを OCR させ、16x と 4x で比べる。レシートは次の 2 枚
    • receipt.png: くっきりした版(560×540)
    • receipt_small.jpg: 半分に縮め、少しぼかし、画質を落とした JPEG(280×270)
  3. レシートの内容について質問する

レシートは、正解の文字列がわかるように PIL(Python の画像ライブラリ)で作りました。精度は 文字誤り率(CER。正解にするために必要な文字の追加・削除・置換の数 ÷ 正解の文字数)で測ります。空白と改行は除いて比べます。

receipt.png(くっきりした版)

receipt_small.jpg(縮小・ぼかし・低画質の版)

動作環境

  • 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 回の結果。実際に使うときは、自分の画像で精度と速度を確かめる必要がある

参考

公式ドキュメント・一次情報

参考にした記事

  • なし
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?