space ocr
指南文章价格文档
developer

Python 发票解析:REST API 指南

本指南将演示如何通过一个简单的 REST API 调用,用 Python 从任意发票图片中提取结构化的 JSON 字段、行项目和可验证的坐标数据。

7 分钟阅读· 2026-08-31

从发票中提取结构化数据是一项常见但令人头疼的任务。你可能有一堆扫描的收据或供应商发票,格式是 JPEG 或 PNG,需要从中提取发票号、总金额和每个行项目,用于会计或数据分析。大多数 OCR 工具只是输出一堆杂乱的文本,你不得不依靠脆弱的正则表达式来艰难地重构数据结构。

其实有更直接的方法:调用一个 REST API,它能直接返回你需要的结构化 JSON,并且每个值都能追溯到其在页面上的精确位置。下面的交互式演示展示了最终效果。点击左侧的任何字段,即可在发票上看到其对应位置的高亮显示。

Invoice with extracted-field bounding boxes
Verified fields
Invoice

Each value with a box carries a verified on-page location — in data.cells[path], that is box + 4-point quad + evidence.match_ratio — on a 0–1000 normalized grid (0,0 top-left → 1000,1000 bottom-right), the same shape the live API returns. Hover a field to trace it back to the pixels it came from.

用于 OCR 处理的发票示例
一张发票图片——这是 /ocr/fields 接口的输入内容。

space-ocr 不是一个需要安装的库,而是一个纯粹的 HTTP 服务。整个过程非常简单:准备一张发票图片,将其发送到 POST /ocr/fields 接口,并定义你希望返回的数据结构(schema)。你可以指定诸如 invoice_number、total_due 这样的字段,以及一个包含描述和价格等列的 line_items 表格。API 会把你声明的结构原样放在 data.values 里返回,坐标和校验结果则分别放在同层的其他键中。

每一个提取出的值都与其在原始图像上的边界框(bounding box)相关联,提供了可供核验的审计追踪。

响应把数据和校验结果分开存放。data.values 就是你声明的结构本身,invoice_number 的 "20250430-001" 也在你定义的位置上。data.cells 是一个以路径为键的扁平映射,cells["total_due"] 里放着该值在页面上的 box 和 quad,以及一个 verified 判定。data.review.flagged 则是一份短清单,列出值得再看一眼的路径和对应的 reasons。下面的结构声明了日期和数字,因此 data.normalized 会一并返回解析后的值,而 values 保持读取到的原始写法。下面的 Python 脚本会依次取出这些内容。

parse_invoice.py
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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
import os
import base64
import json
import requests

# Get your API key from an environment variable
API_KEY = os.environ.get("SPACE_OCR_API_KEY")
IMAGE_PATH = "./path/to/your/invoice.jpg" # Path to your invoice image
API_URL = "https://api.space-ocr.com/ocr/fields"

if not API_KEY:
    raise ValueError("API key not found. Set the SPACE_OCR_API_KEY environment variable.")

# Declare the schema you want back.
# 'type' adds a deterministic parse under data.normalized; 'required' and 'pattern'
# are checked after extraction and show up in the review list when they fail.
# Declarations never reach the model: they do not change the value, only the signals.
fields_schema = [
    {"name": "supplier", "type": "string", "description": "The name of the company that issued the invoice."},
    {"name": "invoice_number", "type": "string", "required": True, "pattern": "^[0-9]{8}-[0-9]{3}$"},
    {"name": "issue_date", "type": "date", "required": True},
    {"name": "total_due", "type": "number", "required": True},
    {
        "name": "line_items",
        "type": "array",
        "description": "All items listed in the invoice table.",
        "children": [
            {"name": "item_description", "type": "string"},
            {"name": "unit_price", "type": "number"},
            {"name": "quantity", "type": "integer"},
            {"name": "line_total", "type": "number"}
        ]
    }
]

# Read the image file and encode it in base64
with open(IMAGE_PATH, "rb") as image_file:
    base64_image = base64.b64encode(image_file.read()).decode("utf-8")

headers = {
    "Authorization": f"Bearer {API_KEY}",
    "Content-Type": "application/json"
}

payload = {
    "image": base64_image,
    "imageType": "base64",
    "fields": fields_schema
}

print(f"Sending request for {IMAGE_PATH}...")
response = requests.post(API_URL, headers=headers, json=payload)

if response.status_code != 200:
    # Errors use one envelope: {"error": {"code", "message", "requestId"}, "details": {...}}
    try:
        err = response.json()["error"]
        print(f"HTTP {response.status_code} {err['code']}: {err['message']} (requestId {err['requestId']})")
    except (ValueError, KeyError):
        # A body over 32MiB is cut off before the app sees it and comes back as HTML
        print(f"HTTP {response.status_code}: {response.text[:200]}")
    if response.status_code == 429:
        print(f"Rate limited - Retry-After: {response.headers.get('Retry-After')} seconds")
    # 400 invalid_image is not charged and a retry will not help; 502 is refunded and worth
    # retrying; 504 means the 180s sync ceiling was hit, so send that page on its own.
    raise SystemExit(1)

data = response.json()["data"]
values = data["values"]                  # your schema, and nothing else
cells = data["cells"]                    # flat map: path -> box / quad / verified / review / evidence
review = data["review"]                  # what is worth a second look
normalized = data.get("normalized", {})  # deterministic parse of the types you declared
image = data["image"]                    # {"width", "height"} - the frame the coordinates use

print("\n--- Extracted Invoice Data ---")
print(json.dumps(values, ensure_ascii=False, indent=2))

# 'values' keeps the text as it was read; the parsed form lives here.
# A null leaf means the parse failed - the reason sits in cells[path]["normalized"]["error"].
print("\nissue_date parsed:", normalized.get("issue_date"))
print("total_due parsed :", normalized.get("total_due"))

# The review queue: every entry carries a path and a rank-ordered reasons array.
print(f"\n{len(review['flagged'])} of {review['declared']} declared fields were flagged:")
for item in review["flagged"]:
    path = item["path"]
    reasons = item["reasons"]      # reasons[0] is the primary one
    cell = cells.get(path)         # 'missing' and 'nobox' have no cell to point at
    box = cell["box"] if cell else None
    print(f"  {path}: {reasons[0]} ({', '.join(reasons)}) box={box}")

# Where a single value came from
total = cells.get("total_due")
if total:
    print("\ntotal_due")
    print("  box     :", total["box"])       # {"xmin", "ymin", "xmax", "ymax"}, 0-1000
    print("  quad    :", total["quad"])      # four {"x", "y"} points, following the page tilt
    print("  verified:", total["verified"])  # False if flagged, True if a check ran clean, None if there was nothing to check
    print("  evidence:", total["evidence"])  # text_match, source, match_ratio, printed_text ...
    left = total["box"]["xmin"] / 1000 * image["width"]
    top = total["box"]["ymin"] / 1000 * image["height"]
    print(f"  pixels  : ({left:.0f}, {top:.0f}) on a {image['width']}x{image['height']} page")

# Line items: values holds the rows, cells is keyed by the indexed path
print("\nline_items")
for i, row in enumerate(values.get("line_items") or []):
    cell = cells.get(f"line_items[{i}].line_total")
    verified = cell["verified"] if cell else None
    print(f"  row {i}: {row.get('item_description')} x{row.get('quantity')} "
          f"= {row.get('line_total')} (verified={verified})")
✓ Verified

为了确保准确性,space-ocr 并不完全依赖语言模型的输出。模型会返回文本值以及它在页面上使用了哪些词的提示。然后,引擎会将提取出的值与页面上实际的 OCR 符号进行逐字符匹配。这个过程会生成 match_ratio 置信度分数(≥ 0.85 即可认为是高置信度匹配)和坐标,两者都放在 data.cells[path] 里:box 是 xmin、ymin、xmax、ymax 四个整数键,quad 则是随页面倾斜的四个点。所有坐标都归一化到 0-1000,因此与原始图像的分辨率无关;要换算成像素时,用 data.image 给出的页面尺寸。比对本身的结果记在 evidence.text_match,而 verified 是这个单元的总判定——只要有任何标记,包括你自己声明的规则被触发,它就会变成 false。

API 按调用次数计费,而不是按字段数量或页面复杂度。每次图片处理费用为 $0.05,如果请求未能成功返回结果则不收取任何费用。新注册账户每月可获得 100 次免费扫描额度。

  1. 获取您的 API 密钥
    注册一个免费的 space-ocr 账户,在控制面板的设置中找到您的 API 密钥。密钥以 'spocr_' 开头。
  2. 准备您的发票图片
    将您的发票保存为常见的图片文件,如 JPEG 或 PNG。记下文件路径,以便在脚本中使用。
  3. 定义您的数据结构 (Schema)
    在您的 Python 脚本中创建一个 JSON 数组,用它来定义您想要提取的字段的名称和类型,包括任何行项目表格。
  4. 编写 Python 脚本
    使用 requests 库编写一个脚本,读取图片文件,将其转换为 base64 编码,然后通过 POST 方法发送到 https://api.space-ocr.com/ocr/fields。请确保在 Authorization 请求头中包含您的 API 密钥。
  5. 运行脚本并处理 JSON
    执行你的脚本。把 `data.values` 载入数据库、会计软件或分析工具,并先核对 `data.review.flagged` 里列出的那几个值。
我需要安装 SDK 或库吗?
不需要。space-ocr 是一个标准的 HTTP REST 服务。您可以使用任何能够发起 HTTP 请求的语言或工具来调用它,比如 cURL 或 Python 的 requests 库。
可以处理 PDF 格式的发票吗?
API 接口接受光栅图像格式,如 JPEG、PNG 或 WebP。要处理 PDF 文件,您需要先在自己的代码中将每一页渲染成图片,然后再发送给 API。space-ocr 的网页应用会自动为您处理这个转换过程。
返回结果中的坐标是什么意思?
API 返回的是 0-1000 范围内的归一化坐标,其中 (0,0) 代表左上角,(1000,1000) 代表右下角。`data.cells` 中的每一项都同时带有 `box`(整数键 `xmin`、`ymin`、`xmax`、`ymax`)和 `quad`(随页面倾斜的四个点)。需要换算成像素时,以 `data.image` 给出的 width / height 为基准,那是读取当下的页面尺寸。
如何处理行项目或表格?
在您的 `fields` schema 中,定义一个 `type` 为 `array` 的字段。然后,在嵌套的 `children` 属性中指定每一行需要提取的列。API 将会为该字段返回一个对象数组。
如果我事先不知道发票的布局怎么办?
如果无法事先确定字段结构,可以在请求体里设置 `autoFields: true`,而不提供 `fields` 数组。服务会检测文档结构并返回它找到的字段。看到返回的字段名之后,再把它们写进 `fields` 显式声明,后续的调用就会稳定下来。
我怎么知道哪些值需要人工复核?
看 `data.review.flagged`。每一项都带有 `path` 和一个按排名排序的 `reasons` 数组,第一个是主要原因,`missing`、`text_mismatch`、`pattern_mismatch` 这类代码说明了被标记的理由。数量就是 `flagged` 的长度,没有单独的计数器。要按值查看时,`cells[path].verified` 用布尔值给出同一个判定,字符层面的细节在 `cells[path].evidence` 里(`match_ratio` 为 0.0 到 1.0,0.85 及以上视为高置信度匹配)。两个引擎如果碰巧读错到一处,这类值仍可能通过,所以这份清单是用来缩小人工复核范围的,而不是取消它。

几分钟内即可开始解析发票。

获取您的 API 密钥,每月享 100 次免费扫描。无需信用卡。

相关文章