生成AIがマルチモーダルを扱えるようになり、紙の帳票のデジタル化が一気に進みそうな気配があります。現場に紙の帳票は、まだまだたくさん残っています。作業日報、点検表など。これらをGASとGeminiを使ってOCRする方法については、下記の記事で紹介しました。
今回は 「結局どのモデルが、安くて、正確なのか?」 を調査してみます
日本語の手書き作業日報を1枚用意し、最新の"最安帯"モデル3つに同じ条件で読ませて、精度・速度・コストを横並びにしてみました。使ったのは次の3モデルです(料金は100万トークンあたり・2026年7月時点)。
| モデル | 入力 / 出力 | 位置づけ |
|---|---|---|
| Gemini 3.1 Flash-Lite | $0.25 / $1.50 | Googleの高効率・低遅延モデル |
| GPT-5.4 nano | $0.20 / $1.25 | OpenAIが「コスト重視ならこれ」と推奨する最安 |
| GPT-5.4 mini | $0.75 / $4.50 | nanoの一段上(約3倍) |
Gemini に関しては、Flash-Liteで十分なOCR精度を発揮することを確認済ですので、Flashモデルは評価していません。
GPT−5.4 nano と Gemini Flash-Lite はほぼ同じ価格帯、GPT-5.4 mini はその一段上です。「ほぼ同じ値段ならどちらが賢いか」「一段課金したらどれだけ変わるか」を評価します。
テストに使った帳票
読ませたのは、建設現場の作業日報を模した手書きの1枚です(記載内容はすべて架空)。OCRの実力差が出るように、"難所"を仕込みました。
- 日本式天気記号:天候欄は「晴」と書かず、○(快晴)/○に縦棒(晴れ)/◎(くもり)/●(雨)/結晶マーク(雪)で手描きしました。記号を天気の意味に変換できるかを見ます
- 天気の移り変わり(気象庁の区切り表記) :1日ぶんは「晴れ/くもり」のように書いています。気象庁の表記では 「/」=「のち」 、 「|」=「時々・一時」 です(気象庁FAQ)。つまり「晴れ/くもり」は 晴れのちくもり。区切り記号まで解釈できるかを見ます
- 手書きの訂正跡:1マスはわざと二重線で訂正してあります(実際の帳票では日常茶飯事)。訂正跡をどう扱うかでモデル差が出ます
-
氷点下+小数:
-1.2(マイナス符号が脱落しないか)、6.0(末尾ゼロを正しく理解できるか) -
英数字混じりの専門用語:
D13(鉄筋径、手書きではDBに酷似)、スランプ18、生コン 24-18-20 - 単位のゆらぎ:本/枚/m³/袋
- ○囲みマーク:進捗欄の「順調・要注意・遅延」のどれを丸で囲んだか
3モデルを同じ条件で叩くコード
公平に比べるため、同じ画像・同じプロンプト・同じJSONスキーマ(構造化出力) で3モデルを呼びます。APIは OpenRouter に一本化して、1つのコードで3モデルを切り替えられるようにしました。
天候欄は「日本式天気記号を天気の名称に変換して」と指示し、responseSchema で表構造をそのまま受け取ります。
import base64, json, os, time
from pathlib import Path
from openai import OpenAI # OpenRouter は OpenAI 互換
MODELS = [
{"id": "google/gemini-3.1-flash-lite", "label": "Gemini 3.1 Flash-Lite", "in": 0.25, "out": 1.50},
{"id": "openai/gpt-5.4-nano", "label": "GPT-5.4 nano", "in": 0.20, "out": 1.25},
{"id": "openai/gpt-5.4-mini", "label": "GPT-5.4 mini", "in": 0.75, "out": 4.50},
]
# 各行のスキーマ。天候は「記号→名称」に変換させる
ROW_PROPS = {
"日付": {"type": "string"},
"天候": {"type": "string", "description": "日本式天気記号を晴れ/快晴/くもり/雨/雪等の名称に変換"},
"気温": {"type": "number"},
"作業内容": {"type": "string"},
"作業人数": {"type": "integer"},
"使用機材資材": {"type": "string"},
"数量": {"type": "string"},
"進捗": {"type": "string", "enum": ["順調", "要注意", "遅延"]},
}
# OpenAI の strict モードは全 object に additionalProperties:false と
# 全プロパティの required を要求する(Gemini は寛容)。両対応で false を明記する
SCHEMA = {
"type": "object", "additionalProperties": False,
"properties": {
"header": {
"type": "object", "additionalProperties": False,
"properties": {k: {"type": "string"} for k in
["記入日", "記入者", "現場名", "工種", "元請担当"]},
"required": ["記入日", "記入者", "現場名", "工種", "元請担当"],
},
"rows": {"type": "array", "items": {
"type": "object", "additionalProperties": False,
"properties": ROW_PROPS, "required": list(ROW_PROPS)}},
"備考": {"type": "string"},
},
"required": ["header", "rows", "備考"],
}
PROMPT = ("これは建設現場の手書き作業日報です。表の各行と基本情報・備考欄を読み取り、"
"指定のJSONスキーマで返してください。\n"
"天候欄は天気記号を天気の名称に変換してください:"
"○=快晴/○に縦棒=晴れ/◎=くもり/●=雨/結晶マーク=雪。\n"
"記号と記号の間の区切りは天気の移り変わりを表します。"
"『/』は『のち』、『|』は『時々(一時)』を意味します。"
"例えば「晴れの記号/くもりの記号」は『晴れのちくもり』です。\n"
"手書きに二重線などの訂正がある場合は、訂正後の記入を採用してください。")
def image_data_url(path):
b64 = base64.b64encode(Path(path).read_bytes()).decode()
return f"data:image/jpeg;base64,{b64}"
def run_model(client, model, data_url):
t0 = time.time()
resp = client.chat.completions.create(
model=model["id"],
messages=[{"role": "user", "content": [
{"type": "text", "text": PROMPT},
{"type": "image_url", "image_url": {"url": data_url}},
]}],
response_format={"type": "json_schema",
"json_schema": {"name": "work_report", "schema": SCHEMA, "strict": True}},
temperature=0,
)
u = resp.usage
cost = (u.prompt_tokens * model["in"] + u.completion_tokens * model["out"]) / 1_000_000
return {"label": model["label"], "parsed": json.loads(resp.choices[0].message.content),
"sec": round(time.time() - t0, 1), "in_tok": u.prompt_tokens,
"out_tok": u.completion_tokens, "usd": cost}
# 実行:3モデルを順に叩いて速度・コストを表示し、結果をJSONに保存
if __name__ == "__main__":
client = OpenAI(base_url="https://openrouter.ai/api/v1",
api_key=os.environ["OPENROUTER_API_KEY"])
data_url = image_data_url("work-report-filled.jpg")
results = []
for m in MODELS:
r = run_model(client, m, data_url)
results.append(r)
print(f'{r["label"]:<22} {r["sec"]}s ${r["usd"]:.4f}')
# 採点スクリプトへ受け渡し
json.dump(results, open("results.json", "w", encoding="utf-8"),
ensure_ascii=False, indent=2)
ポイント: OpenAIの構造化出力(strictモード)は、スキーマ内のすべてのオブジェクトに additionalProperties: false と、全プロパティの required 記載を求めます(記載がない場合 400 Invalid schema になります)。
採点:正解データと突き合わせる
正解をJSONで用意し、フィールド単位で突き合わせます。全半角・空白・日付書式のゆらぎは受け入れます。
import json, unicodedata
def norm(v):
s = unicodedata.normalize("NFKC", str(v))
s = s.replace("年", "/").replace("月", "/").replace("日", "") # 日付書式を吸収
return s.replace(" ", "").replace("・", "").replace(" ", "").replace("-", "").lower()
def eq(a, b):
try:
return abs(float(a) - float(b)) < 1e-6 # 8.2 と "8.2"、6.0 と 6 を一致扱い
except (ValueError, TypeError):
return norm(a) == norm(b)
# 正解データ(帳票の中身を手で用意したJSON)。スキーマのキー名との対応表
GT = json.load(open("ground-truth.json", encoding="utf-8"))
HEADER = {"記入日": "記入日", "記入者": "記入者", "現場名": "現場名",
"工種": "工種", "元請・担当": "元請担当"}
ROW = {"日付": "日付", "天候": "天候", "気温": "気温", "作業内容": "作業内容",
"作業人数": "作業人数", "使用機材・資材": "使用機材資材",
"数量": "数量", "進捗": "進捗"}
def score(parsed):
ok = sum(eq(GT["header"][g], parsed["header"].get(m, "")) for g, m in HEADER.items())
total = len(HEADER)
for gt_row, p_row in zip(GT["rows"], parsed["rows"]):
ok += sum(eq(gt_row[g], p_row.get(m, "")) for g, m in ROW.items())
total += len(ROW)
return ok, total
# 実行:比較スクリプトが保存したJSONを読み、採点して総合精度を表示
if __name__ == "__main__":
results = json.load(open("results.json", encoding="utf-8"))
for r in results:
ok, total = score(r["parsed"])
print(f'{r["label"]:<22} {ok}/{total} 正解 {round(100 * ok / total)}%')
結果
| モデル | ヘッダ | 天候 | 全項目 | 総合 | 秒 | 実コスト |
|---|---|---|---|---|---|---|
| Gemini 3.1 Flash-Lite | 5/5 | 6/6 | 48/48 | 100% | 5.0 | $0.0015 |
| GPT-5.4 nano | 1/5 | 3/6 | 24/48 | 47% | 6.3 | $0.0014 |
| GPT-5.4 mini | 5/5 | 5/6 | 45/48 | 94% | 5.1 | $0.0051 |
タスクの内容によるかもしれませんが、かなり圧倒的な結果になりました。
1. 最安帯の勝者は Gemini 3.1 Flash-Lite(満点)
nano とほぼ同じコスト($0.0015)で、精度は 100%。ヘッダも本文も、後述の「移り変わり記号」も、二重線で訂正されたセルまで、すべて正しく読みました。既にGeminiモデルのOCR精度の高さは認識していましたが、今回のタスクでも非常に優秀でした。
2. GPT-5.4 nano は"それっぽい嘘"を返した
同じ最安帯ですが 47%。天候欄では、快晴(○)もくもり(◎)も両方「晴れ」と誤読——日本式天気記号を判読できませんでした。さらに注意したいのは、読めない箇所を空欄にせず、もっともらしい別の言葉で埋めてくる傾向です。
- 現場名「東町マンション新築工事」→ 「東側マンション新築工事」
- 記入者「山田太郎」→ 「よだまち部」
出力のJSONは整っていて一見それらしいので、目視チェックなしに信頼すると危険です。モデルを選ぶときはタスクに合わせて精度も確認するべき、という良い実例になりました。
3. GPT-5.4 mini は正確(94%)だが割高
94%と高精度ですが、コストは Flash-Lite の 約3.4倍。この用途(日本語手書き帳票)に限れば、mini を選ぶ理由が見当たらないという結果になりました。
外したのは48項目中3つでした。ひとつは二重線で訂正された天候欄のセルで、最終的な記入は「晴れ」なのに mini は 「晴れ時々くもり」 と読みました(Gemini Flash-Lite は同じセルを正しく「晴れ」と読解)。残る2つは手書き特有の一字違いで、 「異形鉄筋」→「黒板鉄筋」 、そして 「現場閉所」→「現場開所」 。とくに後者は"閉"と"開"が入れ替わって意味が逆転してしまいます。
4. プロンプトで「区切り記号」の説明が必要
実は最初、区切り記号の意味を説明せずに投げたところ、3モデルとも「晴れ/くもり」を「晴れ」止まりで読み落としました。プロンプトに「『/』=のち、『|』=時々」と一文足したところ、Gemini は「晴れのちくもり」まで正しく取れるようになりました。同じモデル・同じ画像でも、ドメイン知識が重要——プロンプト設計の重要性の例になりました。
結論
- 日本語の手書き帳票を安く読むなら、まず Gemini 3.1 Flash-Lite がおすすめ。最安帯で高い精度を発揮します。
- 「コスト」だけで モデルを選択しない。ほぼ同じ価格でもタスクによって得意・不得意があります。
- GPT-5.4 mini でも、この用途では Flash-Lite を上回れなかった。モデルは"高い=正確"とは限りません。ベンダーにこだわらず、タスクごとにテストすることがおすすめです。
モデルは数か月で入れ替わります。この記事の数字も"2026年7月時点のスナップショット"として、現場のデータで測り直すことが必要だと思います。
なお、読み取った結果を実際にGAS(Google Apps Script)だけでスプレッドシートへ自動入力する方法は、冒頭でも紹介した記事で解説しています。あわせてご覧ください。
私たちPROMPT-X(プロンプトX)は、こうした「現場の困りごとをAIとデータで解く」開発を、地方拠点から全国のプロジェクトで手がけています。未経験からの参加も歓迎しています。
- 地方から、全国レベルの開発に挑戦しませんか? → 採用情報

