スキャンから、人が読む順序どおりのテキストを取り出す
素の OCR はページ上の全単語を検出順で返します。それは誰かが読む順序ではありません。読み順を直したプレーンテキスト抽出、ブロックごとの座標、そして「書き換えられていないか」の検証について。
「テキストだけ欲しい」— 一番簡単そうな要望に見えて、多くの OCR 連携が静かに取り違えるのがこれです。
ビジョン OCR エンジンは、見つけた単語を段落にまとめて 検出順 で返します。ページ全体をおおむね上から下、左から右へ。単段組みのメモならたまたま読む順と一致します。二段組みの記事、サイドバーのある請求書、少し傾いてスキャンされたページでは一致しません。左段の第 1 段落、次に右段の第 1 段落、また左段へ。単語はすべて正しい。文書としては意味を成さない。
そのテキストが検索インデックスや埋め込み、差分比較に入るなら、被害は静かです。エラーは出ず、結果がじわじわ悪くなり、誰も OCR 工程まで遡りません。
構造がなくても、プレーンテキストに必要な 3 つ
保存すべき構造がなくても、使えるテキスト抽出は 3 つの問いに答える必要があります。
- どの順か — ブロックが読み順で出てくること。行末で折れた文が 2 つの断片のままにならず、繋ぎ直されること。
- どこから来たか — 段落を索引化した後で「なぜこの文書がヒットしたのか」を人に見せるなら、その段落の座標が要ります。ページ番号では足りません。
- ページの通りか — 転写する言語モデルは、同時に「整えて」しまうこともあります。黙って行われた改善は、誤りより厄介です。正しく読めてしまうからです。
呼び出しは 1 回
POST /ocr/text は画像を受け取り、その文書のテキストを返します。includeBlocks を付ければ、各ブロックが座標つきで一緒に返ります。
curl -X POST https://api.space-ocr.com/ocr/text \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"image": "https://example.com/page.jpg",
"imageType": "url",
"includeBlocks": true
}'{
"status": "success",
"data": {
"text": "株式会社サクラ商事\n請求書\n合計 1,451",
"source": "llm",
"image_size": { "width": 1654, "height": 2339 },
"review_summary": {
"unit": "block",
"total": 12,
"boxed": 12,
"text_verified": 11,
"text_mismatch": 1,
"needs_review": 1,
"flagged": [{ "path": "blocks[7]", "reason": "text_mismatch" }],
"recovered_blocks": 0,
"token_coverage": 1.0
},
"blocks": [
{
"text": "株式会社サクラ商事",
"bbox": { "xmin": 60, "ymin": 48, "xmax": 470, "ymax": 92 },
"vertices": [{ "x": 60, "y": 48 }, "…"],
"bbox_source": "token_id",
"text_verified": true,
"needs_review": false
}
]
}
}text は文書全体を 1 本の文字列にしたもの(ブロックを改行で連結)。blocks[] は同じ内容を、実際に位置が特定できた単位で分けたもので、各ブロックに 0〜1000 正規化の bbox と vertices が付きます。
役割分担ははっきり書いておく価値があります。文字と座標の正解はビジョン OCR が持ち、言語モデルには順序とまとまりだけを聞いています。 どのブロックが次に来るか、どの断片が同じまとまりかはモデルが決めますが、座標を作り出すことはできませんし、文字についても最終決定権を持ちません。
text_verified と、それが false のとき
各ブロックには text_verified が付きます。エンジンはそのブロックが主張する単語トークンを取り、その座標でビジョン OCR が読んだ内容を引き、正規化して突き合わせます。true は 2 つの読みが一致したという意味。false は転写がページと違うという意味で、テキストは返しつつ、黙って間違える代わりに印が付きます。
これは精度のパーセンテージではありません。もっと狭く、もっと実用的な問いに答えています — これは画素と突き合わせて、一致したのか? 索引化のパイプラインなら、すべて索引に入れつつフラグは保持し、最終的に人に提示するときに不確かなブロックを事実として出さない、という運用が自然です。
2 つの失敗モードは隠さず明示的に扱われます。 モデルのパスが完全に失敗した場合(キーがない・クォータ切れ・リトライ尽き)、エンドポイントはエラーにせずビジョン転写に強制的に切り替え、source: "vision" と warning でそう告げます。また、どのブロックも主張しなかったページトークンの区間は回収ブロック(bbox_source: "unclaimed_tokens")として末尾に戻され、落ちた段落が出力から消えるのではなく出力に現れます。
モデルを切るべきとき
useLlm: false はビジョン専用の転写を返します。即時・モデル費用ゼロ、その代わりブロックは raw OCR 順です。順序が自明に正しい単段組みのページや、キーワードの有無だけ見ればよい一括処理では、こちらが正しい取引です。
順序が意味を運ぶ場面では入れておきます — 多段組み、サイドバーのある帳票、傾いたスキャン、そして機械照合だけでなく人に見せるテキスト。
そして文章ではなく構造が欲しいなら(見出しは見出しとして、表は表として)別のエンドポイントです。POST /ocr/markdown が、要素ごとの同じ座標と同じ text_verified を持つレイアウト保存 Markdown を返します。