space ocr
ガイド記事料金ドキュメント
receipts

請求書から明細行を自動で抽出する

請求書やレシートの明細行を、構造化された行データとして自動で抽出。配列フィールドを宣言すれば1明細につき1行が返り、値ごとの出所座標と、確認すべき値の一覧(review.flagged)が付いてきます。そのままCSVにも書き出せます。

7 分で読了· 2026-08-31

請求書やレシートはデジタル化したいという要望が最も多い書類ですが、いちばん厄介なのはヘッダー部分ではありません。取引先名、日付、請求書番号——こうした単一の値はOCRモデルが一発で拾えます。本当に大変なのは真ん中にあるテーブルです。明細行の数は書類ごとにバラバラで、各行に品名・数量・単価が並んでいて、それを合計・突合し、台帳に取り込める「きれいな行」として取り出さなければなりません。

このガイドでは、space-ocr を使って請求書から明細行を自動で抽出する方法を紹介します。フラットなテキストとしてではなく、1明細が1行になった構造化された配列として、しかも各セルが「ページ上のどこから読み取られたか」を指し示したまま取り出します。テーブルだけでなく書類全体を抽出したい場合は、まず請求書・レシートOCRの全体的な手引きから読み始めてください。

コツ: 明細行を array フィールドとして宣言する

たいていのOCR APIでは、テーブルを1本の文字列として抽出し、あとは自分でパースさせられます。space-ocr なら、明細テーブルの形をスキーマの一部として記述できます。type: "array"children リストを持つ FieldSpec が、エンジンにこう伝えます——この領域は繰り返され、繰り返しごとにこれらのサブフィールドを持つ、と。

以下は、あるレシートのスキーマ例です。商品("items")フィールドは配列で、その children は 商品名(name)・数量(quantity)・単価(unit price)です。

fields[] — 明細行を配列として宣言
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
  "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件の明細行が得られます——ポッカレモン100359シール割引-34(値引き行。符号もそのまま保持)、エキストラBオリー698、といった具合です。行パーサーも、列の分割処理も、正規表現も書いていません。形を一度宣言しただけです。

1枚の請求書から明細行を抽出する
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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].単価 がその行の単価にあたります。各エントリは boxquadverifiedreviewevidence を持ち、確認すべきパスは data.review.flagged に並びます。レシートの1行は、たとえばこんな形です。

data — 商品[0] 行とその子(抜粋)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
{
  "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 のグリッド上の boxxmin/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結果を検証するを参照してください。

✓ Verified

モデルはこれらの座標を作り出してはいません。 言語モデルが返すのは各明細行のテキスト——加えて、どの単語トークンを使ったかというヒント——だけで、ボックスそのものは返しません。エンジンはそのテキストを、ビジョンOCRが実際にページ上で検出したシンボルと文字単位で突き合わせ、各値がどれだけ見つかったかを match_ratio として報告します。モデルのトークンヒントは行が繰り返されると不安定になりがちなので、それを鵜呑みにせず、列方向・行方向の整合性チェックで検証します——これは、行同士が似通って見える30行のテーブルでこそ効いてきます。だからこそ、このテーブルは「もっともらしい」だけでなく検証できるものになるのです。各行は、ページとどれだけよく一致したかのスコアを携えています。

行をクリックすれば、そのピクセルへ

すべての明細行が「自分がどこにあるか」を知っているので、テーブルのスポットチェックはクリック一発になります。アプリ上で任意のセル——品名、数量、単価——をクリックすると、元画像でその値が読み取られた領域がそのまま拡大表示で強調されます。明細が30行ある請求書でも、ページ全体を見渡すのではなく、おかしく見える1行へ視線を直行させられます。

明細行のセルをクリック → 元の請求書上で対応する領域が光ります。

明細行を、会計ソフトが読めるCSVへ

明細行をいったんシートに保存すれば、配列の形が活きるのが書き出しの場面です。space-ocr は書き出し時に配列フィールドを展開します——ヘッダーは # とスカラー列に加えて、配列の child ごとに colName.childName という名前の列が1つずつ付きます(つまり 商品.商品名商品.数量商品.単価)。各明細行はそれぞれ独立したサブ行になります——10件の明細があるレシートは10行になり、いずれも同じ店舗名と日付を持ちます。これはまさに、表計算ソフトや台帳のインポーターが期待する縦長でフラットな形式です。

シートを書き出すと、配列の明細行が 1明細1行 に展開され、colName.childName 列が付きます。

このレシートを書き出すと、抜粋でこんな形になります。

#店舗名日付商品.商品名商品.単価
1KINSHO2019年08月17日ポッカレモン100359
2KINSHO2019年08月17日エキストラBオリー698
3KINSHO2019年08月17日シール割引-34

ファイルはBOM付きのUTF-8なので、日本語・韓国語・中国語の品名もExcel(エクセル)できれいに開けます。手作業で修正した値があれば、書き出し時にはその修正がOCRの値を上書きしますが、元の値も記録として残ります。

この数字で計算するつもりなら、数量単価number(または integer)として宣言しておくとよいでしょう。宣言した型がモデルに渡ることはないので、抽出テキストは変わりません。増えるのは data.normalized という層で、data.values とまったく同じ形の疎なツリーに、決定的に解釈された値が並びます——"359"359 として届きます。解釈できなかった葉は null になり、理由はそのパスのセルに、review の理由としては type_mismatch が載ります。出所の検証と業務ルールは答える問いが違うので、両方を回す価値があります——数量 × 単価 を明細金額と突き合わせ、出所が確認できなかった値は data.review.flagged から片付ける、という形です。

画像フォルダから表計算までの一連の流れは、スキャン書類をCSVへを参照してください。

数ステップでやってみる

  1. 明細行用の配列フィールドを定義する
    fields[] スキーマに、type "array" と children リスト(例: description, qty, unit_price)を持つフィールドを追加します。これでエンジンに、明細行の領域がそれらのサブフィールドとともに繰り返されることを伝えられます。
  2. 請求書を /ocr/fields に送る
    画像(URL または base64)を imageType とあなたの fields[] とともに https://api.space-ocr.com/ocr/fields に POST します。配列フィールドが、1明細につき1オブジェクトのリストとして返ってきます。
  3. 各行を検証する
    まず data.review.flagged を順に見ます。各エントリにパスと reasons が入っています。そのパスで data.cells を引けば box・quad・verified・evidence が得られますし、アプリでセルをクリックして画像上の正確な領域へ飛び、値を確認することもできます。
  4. CSVへ書き出す
    シートを書き出すと、配列の children が colName.childName 列に展開され、各明細行がそれぞれ独立した行になり、書類レベルのフィールドが繰り返されます。そのまま会計ソフトに取り込める形です。
請求書から明細行を自動で抽出するにはどうすればいいですか?
明細テーブルを type "array" と children リスト(例: description, qty, unit_price)を持つフィールドとして宣言し、画像を /ocr/fields に POST します。エンジンは配列を行のリスト——1明細につき1オブジェクト——として返すので、テーブルをパースするコードを書く必要はありません。各行とその子は data.cells にも items[0]・items[0].qty のようなパスで載り、box・quad の座標に加えて verified の判定と review の理由を持っています。
請求書ごとに明細行の数が違っても、OCRで対応できますか?
はい。配列フィールドは行数を固定で仮定しません。上の例のレシートからは10件、別の請求書なら30件返ってくることもあります。繰り返しの行を読み取って値として返すのはモデルで、エンジンはその値をページ上で検出された OCR のシンボルと文字単位で突き合わせ、座標をその行の中に位置づけます。ページにある行数ぶんだけ明細オブジェクトが得られ、それぞれが data.cells の自分のパスで参照できます。
明細行はCSV書き出しでどう表示されますか?
配列フィールドは書き出し時に展開されます。ヘッダーは '#' とスカラー列に加えて、配列の child ごとに colName.childName という名前の列が1つずつ付きます(例: items.description, items.qty, items.unit_price)。各明細行はそれぞれ独立したサブ行になり、取引先や日付といった書類レベルのフィールドが繰り返されます。これは台帳や表計算のインポーターが期待するフラットな形式です。ファイルはBOM付きのUTF-8なので、ExcelでCJK(日中韓)の文字もきれいに開けます。
明細行が正しく読み取れたかは、どうやって分かりますか?
まず data.review.flagged を見てください。検証を通らなかったパスが1件ずつ並び、理由はランク順の reasons 配列(text_mismatch・missing など、文書化された理由コード)で付きます。件数は flagged.length です。そのパスで data.cells[path] を引くと evidence.match_ratio があり、その値の文字がページ上でどれだけ特定できたかを示します——1.0 なら全文字、0.85 以上は信頼できるマッチとして扱われます。これは判定の門ではなく裏付けです。アプリではセルをクリックして、その値が来た領域をそのまま強調表示することもできます。
英語以外の請求書でも動きますか?
はい。言語の検出は自動です——日本語・韓国語・中国語・英語が1つのエンジンで処理され、全角文字にも対応します。上の例では、日本語のレシートの 商品(items)配列を、商品名・数量・単価 の children とともに抽出しています。設定すべき言語フラグはありません。
関連記事