自動從發票擷取明細品項
自動從發票與收據擷取明細品項並整理成結構化的列。宣告一個陣列欄位,每個品項就是一列,每個值都帶有來源座標,需要複查的值集中在 review.flagged,之後可直接匯出成 CSV。
發票和收據是大家最想數位化的文件,可是最難搞定的從來都不是表頭。廠商名稱、日期、發票號碼這些都是單一的值,OCR 模型一次就能抓到。真正麻煩的是中間那張表:明細品項數量不固定,每一項都有品名、數量和金額,得整理成乾淨的資料列,才能加總、對帳,再匯入帳本。
這份指南會示範怎麼用 space-ocr 自動從發票擷取明細品項——不是攤平成一團文字,而是抽成結構化的陣列,每一行都是獨立的一列,而且每一格都還能對應回它在頁面上被讀取的精確位置。如果你要擷取的是整份文件,而不只是表格,請先從更完整的發票與收據 OCR 教學開始。
訣竅:把明細品項宣告成 array 欄位
大多數 OCR API 都只能讓你把表格當成一整串字串擷取出來,再自己去解析。space-ocr 則讓你直接在 schema 裡就把明細表描述清楚。一個帶有 children 清單的 type: "array" FieldSpec,等於在告訴引擎:這個區域會重複出現,而且每次重複都包含這些子欄位。
以下是一張收據的 schema 範例。商品(「items」)欄位是一個陣列,它的 children 是 商品名(品名)、数量(數量)和 単価(單價):
{
"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 到 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" }
] }
]
}'每一個明細品項都能獨立驗證
明細擷取最常出錯的地方就在這裡:模型回傳一張看起來工整的表,但其實微妙地對不齊——某個金額往上錯了一列,某個品名跟下一行黏在一起。
回應把這兩件事分開擺。業務資料放在 data.values,形狀就是你宣告的 schema;值從哪裡來、有沒有通過驗證,則由 data.cells 負責——這是一張以路徑為鍵的扁平對應表:商品[0] 是第一列整體(該列的聯集框),商品[0].単価 是這一列的單價。每一項都帶著 box、quad、verified、review 和 evidence,需要複查的路徑則列在 data.review.flagged。收據裡單獨一列長這樣:
{
"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 裡的 359(xmin/ymin/xmax/ymax,原點在左上角),還附帶四個會跟著文件傾斜角度走的 quad 頂點。把這些數字換算回像素的基準是 data.image,也就是實際判讀時那一頁的寬和高。
這段文字實際上有多少在頁面上被找到,由 evidence.match_ratio 給出:1.0 代表每一個字元都定位到了,引擎把 ≥ 0.85 視為可信的比對。不過它是佐證,不是放行的關卡。真正的檢查清單是 data.review.flagged,每條路徑一項,理由放在依排名排序的 reasons 陣列裡(第 0 個是主要理由),件數就是 flagged.length。把剩下的資料列依比對比率排序、先看最弱的那幾筆,仍然是有用的第二步。完整的運作機制請參考用邊界框驗證 OCR 辨識結果。
那些座標不是模型編出來的。語言模型回傳的是每個明細品項的文字,外加它用了哪些 word token 的提示,但從不回傳框。接著由引擎拿這段文字,去和視覺 OCR 在頁面上實際偵測到的符號做逐字元比對,並針對每個值回報一個 match_ratio,表示它找到了多少。模型給的 token 提示在重複的資料列之間往往不太穩,所以系統不會盲目信任,而是用欄一致性與列一致性檢查去驗證——這一點在 30 列、每行看起來都很像的表格上尤其重要。這就是讓表格可被檢查、而不只是看起來合理的關鍵:每一行都帶著一個分數,說明它和頁面比對得有多好。
點一下某一行,直接落在像素上
因為每個明細品項都知道自己在哪裡,抽查一張表就變成點一下的事。在 app 裡點任何一格——品名、數量、單價——原始圖片就會把這個值的來源區域精確地高亮起來,還附上放大裁切。就算是一張三十行的發票,你的視線也能直接落在看起來不對勁的那一行,不必整頁一行行掃過去。
從明細品項到會計工具讀得懂的 CSV
明細品項一旦存進工作表,匯出時陣列結構的好處又派上用場了。space-ocr 會在匯出時把陣列欄位展開:表頭變成 # 加上各個純量欄位,再為每個陣列 child 各加一欄,欄名為 colName.childName(也就是 商品.商品名、商品.数量、商品.単価)。每個明細品項都各自成為一列子資料列——一張有 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,原因記在該路徑的 cell 上,review 理由則是 type_mismatch。來源驗證和業務規則回答的是不同的問題,兩邊都跑才穩妥:拿數量 × 單價 和明細金額對一次,再從 data.review.flagged 逐筆處理來源沒驗上的值。
完整的「圖片資料夾到試算表」流程,請參考掃描文件轉 CSV。
幾個步驟就搞定
- 為明細品項定義一個陣列欄位在你的 fields[] schema 裡加入一個 type 為 "array" 且帶有 children 清單的欄位,例如 description、qty、unit_price。這會告訴引擎:明細品項區域會帶著這些子欄位重複出現。
- 把發票送到 /ocr/fields把圖片(以 URL 或 base64 的形式)連同 imageType 和你的 fields[] 一起 POST 到 https://api.space-ocr.com/ocr/fields。陣列欄位會以清單回傳,每個明細品項一個物件。
- 驗證每一行先把 data.review.flagged 走一遍:每一項都會給出一個 path 和它的 reasons。用這個 path 去 data.cells 取 box、quad、verified 和 evidence,也可以在 app 裡點那一格,跳到圖片上的精確區域確認該值對不對。
- 匯出成 CSV匯出工作表時,陣列 children 會展開成 colName.childName 欄位,每個明細品項各自成為一列,並重複帶上文件層級的欄位,直接就能餵給你的會計工具。