自动从发票中提取明细行
自动从发票和收据中提取明细行并整理成结构化的行。声明一个数组字段,每条明细就是一行,每个值都带有来源坐标,需要复核的值集中在 review.flagged,之后可直接导出为 CSV。
发票和收据是大家最想数字化的单据,可最难啃的部分从来都不是表头。供应商名称、日期、发票号这些都是单值,OCR 模型一次就能抓出来。真正让人头疼的是中间那张表:明细行数量不固定,每行都带着品名、数量和单价,得整理成干净的行,才能用来合计、对账,再导入账簿。
本文就讲怎么用 space-ocr 自动从发票中提取明细行——不是把它压成一团文本,而是抽取成结构化数组,每一行都是独立的一条记录,而且每个单元格都能精确指回它在页面上被读取的那个位置。如果你要提取的是整份单据,而不只是表格,建议先看更全面的发票与收据 OCR 实操指南。
诀窍:把明细行声明为 array 字段
大多数 OCR API 都只能让你把整张表当成一个字符串提取出来,再自己去解析。space-ocr 则允许你把明细表写进 schema 里直接描述清楚。一个带 children 列表的 type: "array" FieldSpec,等于在告诉引擎:这块区域会重复出现,每次重复都包含这几个子字段。
下面是一张收据的 schema 示例。商品("items")字段是一个数组,它的子字段分别是 商品名(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 到 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 报告每个值被找到了多少。模型给的词元提示在重复的行之间往往不太稳,所以系统不会盲目采信,而是用列一致性和行一致性检查来校验——这一点在一张 30 行、各行长得都很像的表上尤为关键。这正是让这张表可核验、而不只是看着合理的原因:每一行都带着一个分数,说明它跟页面匹配得有多好。
点一下某行,直接定位到像素
因为每条明细行都知道自己在哪儿,抽查一张表就成了点一下的事。在应用里点击任意单元格——某个描述、某个数量、某个单价——原图就会高亮出这个值的来源区域,还附上一个放大裁切。哪怕是一张三十行的发票,你的视线也能直接落到那个看起来不对劲的地方,不用整页一行行扫过去。
从明细行到账务工具能读的 CSV
明细行一旦存进表格,导出时数组结构的优势又一次显现出来。space-ocr 在导出时会展开数组字段:表头变成 # 加上各个标量列,再为每个数组子字段各加一列,列名为 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,也可以在应用里点击该单元格,跳到图片上的精确区域确认值对不对。
- 导出为 CSV导出表格时,数组子字段会展开成 colName.childName 列,每条明细行各自成为一行,并重复带上单据级字段,可以直接交给你的账务工具使用。