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

画像内の表データをCSVに抽出する方法

表、注文書、納品書の写真から、クリーンなCSVファイルを作成します。space-ocr が明細行をどう読み取り、行単位の検証が確認すべき値をどう表に出すかを解説します。

4 分で読了· 2026-08-31

スキャンした納品書や注文書の表データを、スプレッドシートに入力するのは骨の折れる作業です。鮮明な画像があっても、結局はピクセルデータに過ぎません。多くの場合、項目、数量、価格などを一行ずつ手でコピー&ペーストする地道な作業が待っています。このプロセスは時間がかかるだけでなく、たった一つの入力ミスがデータセット全体を狂わせてしまう原因にもなります。

表形式の注文書・納品書
明細行が並ぶ表 — 多くの行を、一貫した形式で出力します。

より良いアプローチは、表の構造をスキーマとして定義することです。テキストブロックを丸ごと抜き出すのではなく、必要な列を宣言します。繰り返し現れる明細行のセクションは array 型のフィールドとし、その子要素(children)に列を並べます。常に数値が入る列には型を宣言します。

1
2
3
4
5
6
7
8
9
10
{
  "name": "items",
  "type": "array",
  "children": [
    { "name": "name",   "type": "string" },
    { "name": "qty",    "type": "integer" },
    { "name": "price",  "type": "number" },
    { "name": "amount", "type": "number" }
  ]
}

行数は指定しません。何行返るかはページ側が決めます。各行は添字付きのパス(values.items[0]、values.items[1] …)で返り、同じパスがそのまま値ごとの座標・検証マップのキー cells["items[0].price"] になります。型を宣言しても抽出される値は変わりません。型がモデルに渡ることはないからです。宣言した型が足すのは、values の隣に並ぶ決定的な第二の層 data.normalized です。

明細行の配列フィールドを定義し、画像をアップロードして表データを構造化されたグリッドに抽出します。

この方法は、値が密集した表でも有効です。システムはまず大規模言語モデルを使って抽出テキストの候補を生成しますが、処理はそこで終わりません。例えば品名の「刻みたくあん」や単価の「580」など、個々の値に対してクロス検証を行います。言語モデルの読みを文書の列構造と照合し、ページ上で元々検出されたOCRシンボルと文字単位でマッチングさせます。値が隣の行にずれ込むと、その座標の文字は多くの場合一致しなくなり、そのまま通らずに要確認として記録されます。

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
38
39
40
41
"data": {
  "values": {
    "items": [
      { "name": "刻みたくあん", "qty": "3",
        "price": "580", "amount": "1,740" }
    ]
  },
  "cells": {
    "items[0]": {
      "box": { "xmin": 263, "ymin": 460,
               "xmax": 738, "ymax": 523 },
      "quad": [ { "x": 263, "y": 460 }, { "x": 738, "y": 460 },
                { "x": 738, "y": 523 }, { "x": 263, "y": 523 } ],
      "verified": null, "review": null
    },
    "items[0].name": {
      "box": {…}, "quad": […],
      "verified": true, "review": null,
      "evidence": { "text_match": true, "match_ratio": 1.0 }
    },
    "items[0].qty": { "box": {…}, "quad": […],
                      "verified": true, "review": null },
    "items[0].price": {
      "box": { "xmin": 693, "ymin": 460,
               "xmax": 738, "ymax": 488 },
      "quad": […],
      "verified": false,
      "review": { "reasons": ["text_mismatch"] },
      "evidence": { "text_match": false, "match_ratio": 0.62 }
    }
  },
  "review": {
    "unit": "field",
    "flagged": [
      { "path": "items[0].price", "reasons": ["text_mismatch"] }
    ]
  },
  "normalized": {
    "items": [ { "qty": 3, "price": 580, "amount": 1740 } ]
  }
}

要確認リストがそのまま作業待ち行列です。data.review.flagged がパスと理由を名指しし、cells["items[0].price"] には照合した座標が入っています。ただし全件を捕まえる仕組みではありません。隣の行に同じ数字が印字されていれば、箱がどちらに付いても文字は一致しますし、両エンジンが同じ誤読で一致した場合は突き合わせる相手がいません。そのため、文字照合では見えない行・列の取り違えは、宣言できる規則として渡しておく価値があります。コード列には pattern、マスタが既に持っている値には enum、妥当な範囲には min と max。違反はいずれも同じ review.flagged に載ります。

最後の層は自前の検算で、これは下流の仕事です。qty を integer、金額列を number として宣言してあるので、data.normalized には解釈済みの数値が並びます("1,740" は 1740)。行の検算は掛け算ひとつで足ります。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const { values, normalized, review } = data;

const rows = values.items.map((item, i) => {
  const n = normalized.items[i];
  const flagged = review.flagged.some((f) =>
    f.path.startsWith(`items[${i}]`)
  );
  return {
    name: item.name,
    qty: n.qty,
    price: n.price,
    amount: n.amount,
    // 人が見る行: 要確認が立った、または検算が合わない
    check:
      flagged || n.qty == null || n.qty * n.price !== n.amount,
  };
});

解釈できなかった葉は normalized で null になり、失敗の種類は cells[path].normalized.error に、要確認リストには type_mismatch として載ります。計算に使う値は normalized から取り、表示する文字は values のままにしてください。座標と検証が付いているのは values の方です。

データが抽出されたら、ワンクリックでシート全体をクリーンなCSVファイルとしてエクスポートできます。
✓ Verified

各値は、その値が載っていたページに突き合わせます。モデルの読みを、そこで実際に検出されたOCRシンボルと文字単位でマッチングし、どれだけ一致したかが evidence.match_ratio に残ります。0.85 以上であれば信頼性の高い一致です。判定そのものは verified で、これは review の鏡です。何かが立てば false、照合が走って何も立たなければ true、照合する相手が無ければ(行のユニオンボックスなど)null になります。座標は一致したシンボルから box と quad として導かれ、data.image を基準に 0–1000 のスケールで正規化されます。これは値がどこから来たかの証拠であって、求めていた値であることの証明ではありません。確認が取れなかった値は review.flagged に並びます。

料金は従量課金制で、画像処理1枚あたり¥10です。毎月100枚分の無料スキャン枠がアカウントに付与されます。何らかの理由で抽出に失敗した場合は、料金は一切かかりません。

  1. シートスキーマを定義する
    新しいシートを作成し、列を定義します。明細行には「配列」タイプを使用し、商品名、数量、価格などの子列を追加します。
  2. 画像をアップロードする
    ドラッグ&ドロップまたはAPIを使用して、表が写った画像をシートにアップロードします。
  3. 抽出データを確認する
    画像がスキーマに沿って処理されます。表の各明細行が、シート上で構造化された行として表示されます。
  4. 必要に応じて修正する
    任意のセルをクリックすると、画像上の対応する領域が表示されます。グリッド上で直接、値を手動で修正できます。
  5. CSVにエクスポートする
    「エクスポート」ボタンをクリックし、CSVを選択します。すべての明細行を含む表データが、クリーンで構造化されたファイルとしてダウンロードされます。
セルが結合されていたり、レイアウトが複雑な場合はどうなりますか?
このシステムは、標準的な行と列で構成された表向けに設計されています。非常に複雑なレイアウトの場合は、複数のスキーマを定義するか、初期抽出後にシート上で手動でデータを調整することができます。
CSVエクスポートでは、明細行はどのように処理されますか?
配列の列名を 'items' とし、その子要素として 'name' と 'price' がある場合、CSVのヘッダーは 'items.name' と 'items.price' になります。画像内の各明細行は、CSVファイル内でそれぞれ別の行として出力されます。
表が記載されたPDFファイルも処理できますか?
はい、ウェブアプリで可能です。PDFファイルをドロップすると、各ページが自動的に画像としてレンダリングされ、処理対象となります。API自体は、JPEGやPNGなどのラスター画像形式を受け付けます。
各セルの座標はどのように決定されるのですか?
抽出された各値について、システムはその文字をページ上で検出されたOCRシンボルと照合します。一致したシンボルから `box` と `quad` が決まり、読み取ったページ(`data.image`)を基準に 0–1000 のスケールで正規化されます。この座標は値がどこから来たかを示す証拠なので、画像の上に描き戻してご自身で確認できます。
表の行数に制限はありますか?
行数の上限を設定する箇所はありません。何行返るかはページ次第です。上限があるのは同期呼び出しの方で、処理は 180 秒で打ち切られ、超えると 504 `ocr_engine_timeout` を返します(この場合は課金されません)。原因は画素数よりも記載の密度であることが多いので、非常に密な表は 1 ページ 1 画像に分けるか、非同期の `POST /upload` をご利用ください。

画像内の表を、使えるデータに

毎月100枚の無料スキャンをご利用いただけます。クレジットカードの登録は不要です。

関連記事