請求書から明細行を自動で抽出する
請求書やレシートの明細行を、構造化された行データとして自動で抽出。配列フィールドを宣言すれば1明細につき1行が返り、値ごとの出所座標と、確認すべき値の一覧(review.flagged)が付いてきます。そのままCSVにも書き出せます。
請求書やレシートはデジタル化したいという要望が最も多い書類ですが、いちばん厄介なのはヘッダー部分ではありません。取引先名、日付、請求書番号——こうした単一の値はOCRモデルが一発で拾えます。本当に大変なのは真ん中にあるテーブルです。明細行の数は書類ごとにバラバラで、各行に品名・数量・単価が並んでいて、それを合計・突合し、台帳に取り込める「きれいな行」として取り出さなければなりません。
このガイドでは、space-ocr を使って請求書から明細行を自動で抽出する方法を紹介します。フラットなテキストとしてではなく、1明細が1行になった構造化された配列として、しかも各セルが「ページ上のどこから読み取られたか」を指し示したまま取り出します。テーブルだけでなく書類全体を抽出したい場合は、まず請求書・レシートOCRの全体的な手引きから読み始めてください。
コツ: 明細行を array フィールドとして宣言する
たいていのOCR APIでは、テーブルを1本の文字列として抽出し、あとは自分でパースさせられます。space-ocr なら、明細テーブルの形をスキーマの一部として記述できます。type: "array" と children リストを持つ FieldSpec が、エンジンにこう伝えます——この領域は繰り返され、繰り返しごとにこれらのサブフィールドを持つ、と。
以下は、あるレシートのスキーマ例です。商品("items")フィールドは配列で、その children は 商品名(name)・数量(quantity)・単価(unit price)です。
{
"fields": [
{ "name": "店舗名", "type": "string", "description": "store name" },
{ "name": "日付", "type": "string", "description": "date" },
{ "name": "合計", "type": "string", "description": "total" },
{
"name": "商品",
"type": "array",
"description": "one row per line item",
"children": [
{ "name": "商品名", "type": "string", "description": "item name" },
{ "name": "数量", "type": "string", "description": "quantity" },
{ "name": "単価", "type": "string", "description": "unit price" }
]
}
]
}これを画像と一緒に POST /ocr/fields に投げると、配列フィールドがリストとして返ってきます。このレシートからは10件の明細行が得られます——ポッカレモン100 が 359、シール割引 が -34(値引き行。符号もそのまま保持)、エキストラBオリー が 698、といった具合です。行パーサーも、列の分割処理も、正規表現も書いていません。形を一度宣言しただけです。
curl -s https://api.space-ocr.com/ocr/fields \
-H "Authorization: Bearer $SPACE_OCR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"image": "https://example.com/receipt.jpg",
"imageType": "url",
"fields": [
{ "name": "total", "type": "string" },
{ "name": "items", "type": "array",
"children": [
{ "name": "description", "type": "string" },
{ "name": "qty", "type": "string" },
{ "name": "unit_price", "type": "string" }
] }
]
}'各明細行は単独で検証できる
明細行の抽出がうまくいかなくなるのは、たいていここです——モデルが一見きれいなテーブルを返してくるのに、よく見ると微妙にずれている。価格が1行上にずれていたり、品名が下の行と混ざっていたり。
レスポンスは、この二つを別の場所に置いています。業務データは宣言したスキーマそのままの形で data.values に入ります。値がどこから来て、検証を通ったかは data.cells の担当で、こちらはパスをキーにしたフラットなマップです——商品[0] が1行目全体(行のユニオンボックス)、商品[0].単価 がその行の単価にあたります。各エントリは box・quad・verified・review・evidence を持ち、確認すべきパスは data.review.flagged に並びます。レシートの1行は、たとえばこんな形です。
{
"values": {
"商品": [
{ "商品名": "ポッカレモン100", "数量": "1", "単価": "359" }
]
},
"cells": {
"商品[0]": {
"box": { "xmin": 96, "ymin": 354, "xmax": 486, "ymax": 380 },
"quad": [
{ "x": 96, "y": 358 }, { "x": 486, "y": 354 },
{ "x": 486, "y": 376 }, { "x": 96, "y": 380 }
],
"verified": null,
"review": null,
"evidence": { "source": "vision_symbol_match", "match_ratio": 1.0 }
},
"商品[0].単価": {
"box": { "xmin": 450, "ymin": 356, "xmax": 484, "ymax": 378 },
"quad": [
{ "x": 450, "y": 360 }, { "x": 483, "y": 356 },
{ "x": 485, "y": 374 }, { "x": 452, "y": 378 }
],
"verified": true,
"review": null,
"evidence": { "text_match": true, "source": "vision_symbol_match", "match_ratio": 1.0 }
}
},
"review": {
"unit": "field",
"flagged": [
{ "path": "商品[3].単価", "reasons": ["text_mismatch"] }
],
"by_reason": { "text_mismatch": 1 }
},
"image": { "width": 1654, "height": 2339 }
}つまり、価格は単なる 359 ではありません。0–1000 normalized のグリッド上の box(xmin/ymin/xmax/ymax、原点は左上)に収まった 359 であり、書類の傾きに沿った4点の quad を伴っています。座標を画素に戻す換算の基準は data.image(実際に判読したページの幅と高さ)です。
そのテキストが実際にページ上でどれだけ見つかったかは evidence.match_ratio が示します。1.0 なら全文字が特定できたという意味で、エンジンは ≥ 0.85 を信頼できるマッチとして扱います。ただしこれは判定の門ではなく、裏付けの材料です。確認の作業リストは data.review.flagged で、パスごとに1件、理由はランク順の reasons 配列(先頭が代表)として並び、件数は flagged.length です。残った行を match ratio で並べ替えて弱いものから見るのは、その次の一手として有効です。仕組みの詳細はバウンディングボックスでOCR結果を検証するを参照してください。
モデルはこれらの座標を作り出してはいません。 言語モデルが返すのは各明細行のテキスト——加えて、どの単語トークンを使ったかというヒント——だけで、ボックスそのものは返しません。エンジンはそのテキストを、ビジョンOCRが実際にページ上で検出したシンボルと文字単位で突き合わせ、各値がどれだけ見つかったかを match_ratio として報告します。モデルのトークンヒントは行が繰り返されると不安定になりがちなので、それを鵜呑みにせず、列方向・行方向の整合性チェックで検証します——これは、行同士が似通って見える30行のテーブルでこそ効いてきます。だからこそ、このテーブルは「もっともらしい」だけでなく検証できるものになるのです。各行は、ページとどれだけよく一致したかのスコアを携えています。
行をクリックすれば、そのピクセルへ
すべての明細行が「自分がどこにあるか」を知っているので、テーブルのスポットチェックはクリック一発になります。アプリ上で任意のセル——品名、数量、単価——をクリックすると、元画像でその値が読み取られた領域がそのまま拡大表示で強調されます。明細が30行ある請求書でも、ページ全体を見渡すのではなく、おかしく見える1行へ視線を直行させられます。
明細行を、会計ソフトが読めるCSVへ
明細行をいったんシートに保存すれば、配列の形が活きるのが書き出しの場面です。space-ocr は書き出し時に配列フィールドを展開します——ヘッダーは # とスカラー列に加えて、配列の child ごとに colName.childName という名前の列が1つずつ付きます(つまり 商品.商品名・商品.数量・商品.単価)。各明細行はそれぞれ独立したサブ行になります——10件の明細があるレシートは10行になり、いずれも同じ店舗名と日付を持ちます。これはまさに、表計算ソフトや台帳のインポーターが期待する縦長でフラットな形式です。
このレシートを書き出すと、抜粋でこんな形になります。
| # | 店舗名 | 日付 | 商品.商品名 | 商品.単価 |
|---|---|---|---|---|
| 1 | KINSHO | 2019年08月17日 | ポッカレモン100 | 359 |
| 2 | KINSHO | 2019年08月17日 | エキストラBオリー | 698 |
| 3 | KINSHO | 2019年08月17日 | シール割引 | -34 |
ファイルはBOM付きのUTF-8なので、日本語・韓国語・中国語の品名もExcel(エクセル)できれいに開けます。手作業で修正した値があれば、書き出し時にはその修正がOCRの値を上書きしますが、元の値も記録として残ります。
この数字で計算するつもりなら、数量 と 単価 は number(または integer)として宣言しておくとよいでしょう。宣言した型がモデルに渡ることはないので、抽出テキストは変わりません。増えるのは data.normalized という層で、data.values とまったく同じ形の疎なツリーに、決定的に解釈された値が並びます——"359" は 359 として届きます。解釈できなかった葉は null になり、理由はそのパスのセルに、review の理由としては type_mismatch が載ります。出所の検証と業務ルールは答える問いが違うので、両方を回す価値があります——数量 × 単価 を明細金額と突き合わせ、出所が確認できなかった値は data.review.flagged から片付ける、という形です。
画像フォルダから表計算までの一連の流れは、スキャン書類をCSVへを参照してください。
数ステップでやってみる
- 明細行用の配列フィールドを定義するfields[] スキーマに、type "array" と children リスト(例: description, qty, unit_price)を持つフィールドを追加します。これでエンジンに、明細行の領域がそれらのサブフィールドとともに繰り返されることを伝えられます。
- 請求書を /ocr/fields に送る画像(URL または base64)を imageType とあなたの fields[] とともに https://api.space-ocr.com/ocr/fields に POST します。配列フィールドが、1明細につき1オブジェクトのリストとして返ってきます。
- 各行を検証するまず data.review.flagged を順に見ます。各エントリにパスと reasons が入っています。そのパスで data.cells を引けば box・quad・verified・evidence が得られますし、アプリでセルをクリックして画像上の正確な領域へ飛び、値を確認することもできます。
- CSVへ書き出すシートを書き出すと、配列の children が colName.childName 列に展開され、各明細行がそれぞれ独立した行になり、書類レベルのフィールドが繰り返されます。そのまま会計ソフトに取り込める形です。