一个返回可验证边界框的 OCR API
大多数 OCR API 都会返回边界框,但坐标系各不相同,而一个框只告诉你值的出处。这是一份面向开发者的指南:data.cells[path] 里归一化到 0–1000 的 box、跟随倾斜的 quad,以及点名哪些值值得复核的 verified / review 约定。
边界框是你验证 OCR 的依据。一个光秃秃的字符串只告诉你模型自以为读到了什么;而一个框会告诉你它是在页面的什么位置读到的,于是你(或你的审核者、或你的代码)可以拿这个值跟原件核对,而不是盲目相信。如果你要把 OCR 接进任何会被审计的场景——发票、报销、KYC、档案管理——"模型返回了 total: 2,045"是不够的;你得能指出 2,045 到底是从哪些像素上来的。
好消息是,大多数主流 OCR API 确实会返回边界框。问题在于,一旦你真动手开发,它们会在三个要紧的地方各不相同——坐标系、是否一并给你结构化字段(而不只是原始文本),以及框旁边那个逐值信号究竟是什么。本文会把这三点逐一讲透,并展示当坐标带着一份明确的复核约定——逐值的判定,加上一份值得复核的路径清单——一起返回时,一个 OCR API 是什么样子。
大多数 OCR API 都返回框——区别在这里
Google Cloud Vision、Tesseract、Amazon Textract 和 Azure AI Document Intelligence 都会随文本一起返回几何信息。它们的分歧在于坐标系、在于你拿到的是结构化字段还是只有原始文本加版式,以及那个逐值数字到底报告了什么。下表汇总的是 2026 年 8 月各家的公开文档——这些产品一直在变,动手估算集成工作量之前,请以最新文档为准再核一遍。
| API | 坐标系 | 结构化字段 | 逐值信号 |
|---|---|---|---|
| Google Cloud Vision | boundingPoly 顶点,单位为源图像的像素(部分能力返回的是 normalizedVertices) | 仅文本 + 几何信息(结构化键值对属于 Google Document AI,是单独的产品) | 每个词/符号的识别置信度(0–1) |
| Tesseract | hOCR / TSV 框,单位为像素(本地库,不是托管 API) | 无——仅原始文本 + 版式 | 每个词的识别置信度(0–100) |
| Amazon Textract | BoundingBox,按页面宽高归一化到 0–1(外加同样是 0–1 的 Polygon) | 表单/表格用 AnalyzeDocument;票据用 AnalyzeExpense | 每个 block 的识别置信度(%) |
| Azure Document Intelligence | 边界多边形,单位为像素(图像)或英寸(PDF) | 预构建/自定义模型 | 每个词的识别置信度 |
| space-ocr | 以你声明的字段路径为键,归一化到 0–1000 的 box,外加跟随倾斜的 quad | 由你用 fields 声明(明细行用 children),或交给 autoFields | verified 判定 + review.flagged 待复核清单(依据在 evidence) |
有两点值得留意。第一,坐标单位没法直接拿去复用——像素框绑死在实际被读取的那张图像上,而归一化的框在缩放后依然有效。第二,逐值那一列量的未必是同一件事:识别置信度回答的是"引擎对自己的读取有多确定",这跟"返回的这个值究竟有没有在页面上被找到"是两个问题。
框是怎么推导出来的,和它的格式同样要紧。 在 space-ocr 中,语言模型返回的只是每个字段的文本,以及它用到了哪些词元(word token)的提示,框本身从来不由它给出。引擎随后拿这段文本,去跟视觉 OCR 在页面上实际检测到的符号做逐字符匹配,于是框就落在这些字符被找到的真实像素上。凡是跑过这道比对的值,其单元格的 evidence 里会带一个 match_ratio,表示它被定位到的比例;要是压根没有可比对的对象,这个键就不会出现。这些词元提示可能带噪声(有时会在重复出现的行之间张冠李戴),所以系统用列一致性和行一致性检查来验证它们,而不是盲目相信。这正是"模型断言的坐标"和"回过头来跟页面核对过的坐标"之间的区别。
space-ocr 为每个值返回什么
业务数据留在 data.values 里,形状就是你请求的那套 schema。而这个值从哪来、有没有通过检查,则集中在另一张以相同路径为键的映射 data.cells 中——比如 total,明细行则是 items[0].price。每个单元格都带着:
box——一个轴对齐矩形{ xmin, ymin, xmax, ymax },由整数构成,位于一个 0–1000 归一化网格上(0,0 = 左上角,1000,1000 = 右下角),与图像的像素尺寸无关。quad——四个有序顶点(左上、右上、右下、左下),构成一个带方向的框,会跟随单据的倾斜角度,所以一张拍歪的手机照片也能干净地框住。它总是和box一起返回。verified——判定,也是review的镜像:只要有任何东西被标记就是false;什么都没标记、而且比对确实跑过,就是true;什么都没标记但根本没有可比对的对象(比如整行的并集框),则是null。review——要么是null,要么是{ reasons }:理由按排序排列,第一个是主因。代码包括text_mismatch、low_ratio、nobox和missing等。evidence——判定背后的原始信号:text_match(逐字符比对本身)、source(vision_symbol_match、token_id)、match_ratio,以及printed_text——OCR 在那组坐标处读到的字形。
同一批路径还会出现在 data.review.flagged 里,那就是待办清单:每个需要看一眼的值一条,附上它的理由。data.image 给出的是实际被读取的那一页的宽高,所有坐标都以它为基准。(如果你声明了标量 type,或给 string 字段加了 pattern、enum,还会多出一层 data.normalized;下面这个只有 string 的例子不会产生它。)
{
"data": {
"values": { "total": "2,045" },
"cells": {
"total": {
"box": { "xmin": 381, "ymin": 803, "xmax": 500, "ymax": 825 },
"quad": [
{ "x": 380, "y": 804 }, { "x": 500, "y": 801 },
{ "x": 500, "y": 823 }, { "x": 381, "y": 826 }
],
"verified": true,
"review": null,
"evidence": {
"text_match": true,
"source": "vision_symbol_match",
"match_ratio": 1.0,
"printed_text": "2,045"
}
}
},
"image": { "width": 1654, "height": 2339 }
}
}像素还是归一化?换算一次,缩放就不再出问题
OCR 集成里有一类反复出现的 bug:像素坐标绑死在你上传的那张图像上——一旦为了存储而缩放或重新压缩,或者漏掉一个 EXIF 旋转标志,叠上去的框就会漂移、被裁切,或者落到错误的文字上。归一化坐标能避开一整类这样的 bug:一个 0–1000 的框可以套到同一页面的任意一种渲染上。
先要弄清一件事:基准面是 data.image,而不是你发过去的那个文件。它是实际被读取的那一页——EXIF 方向已经烘进像素里,大图在读取前还会被缩小——所以它的 width 和 height 有可能跟你上传的正好对调(发 4000×3000,拿回 3000×4000)。以 data.image 为基准换算,算式就不会错。
要在显示出来的图像上画一个框,只需换算一次:
- SVG 叠加层——给 SVG 设
viewBox="0 0 1000 1000",然后原样画出box或quad。 - 绝对定位的 div——
leftPct = xmin / 1000 * 100、topPct = ymin / 1000 * 100、widthPct = (xmax - xmin) / 1000 * 100、heightPct = (ymax - ymin) / 1000 * 100。 - 换回像素——
pixel_x = box.xmin / 1000 * data.image.width、pixel_y = box.ymin / 1000 * data.image.height。
由于 EXIF 方向已经应用在那一页上,一张旋转过的手机照片(方向 6/8)也不用你这边再做一遍纠正。但页面不会被摆正(deskew):拍歪的照片依旧是歪的,这正是为什么 quad 跟着倾斜走,而 box 在它周围保持轴对齐。
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": "vendor", "type": "string" },
{ "name": "total", "type": "string" }
]
}'置信度分数、匹配比例,和判定
这个区别值得你记牢。大多数 OCR API 都会给出一个识别置信度——一个反映引擎对自己读取结果有多确定的数字,依据的是字体清晰度、图像质量之类的因素。它有用,但说到底是模型在给自己的作业打分。而匹配比例度量的是外部的事实:在模型返回的那个值的字符里,有多少真的能在页面级 OCR 检测到的符号中被找到。一个值可能带着相当高的识别置信度被返回,却依然跟页面上的任何东西都对不上。
不过 API 并不会把这个比例丢给你、让你自己挑一个阈值。match_ratio 作为判定的依据放在 evidence 里;阈值由引擎自己施加——达到 0.85 及以上才算可信的字符匹配——覆盖不足时,该单元格会带着 review 里的 low_ratio 返回。所以你代码里的闸门是 review != null,更好的做法是直接遍历 data.review.flagged,它还能覆盖比例看不见的那几类:声明为 required 却压根没回来的值(missing)、没有坐标的值(nobox)、违反了你所声明的 pattern 或范围的值。
有一种组合常让人意外,其实不必:verified: false 与 evidence.text_match: true 同时出现。这说明这个值的字符跟页面对得上,是被你声明的规则拦下的。两者都值得复核,只是理由不同——而且哪一种都不能反过来给对方背书,因为两个引擎也可能在同一个误读上达成一致。
先验证,再查询——无需重跑 OCR
数据留得下来,坐标才最有用。用 POST /upload 把图像推入一张表,再用 GET /view 在服务端查询它——where、sort、select、limit、offset——比如把所有 total >= 40000 的行拉出来,既不重跑 OCR,也不再付一次费。筛选的对象是这张表自己的列(外加 name、ocrStatus、createdAt),每一行都会连同完整的 cells 映射一起返回,所以 box 和 quad 都还在;想要更轻量的响应体,就加上 boxes=0 把它们去掉。关于验证工作流的深入讲解,参见 用边界框验证 OCR 和 OCR 审计记录。
如何从 API 拿到可验证的边界框
- 请求字段把图像 POST 到 /ocr/fields,imageType 设为 'url' 或 'base64',再附上你自己的 fields 数组,或者把 autoFields 设为 true。引擎读取的是栅格图像。
- 读取坐标按字段路径查 data.cells。每个单元格都带着 0–1000 网格上的 box { xmin, ymin, xmax, ymax }、四点 quad、判定 verified、review 和 evidence。
- 叠加或换算用 viewBox 为 '0 0 1000 1000' 的 SVG 画框,或用 pixel_x = box.xmin / 1000 * data.image.width 换算成像素。data.image 就是实际被读取的那一页,EXIF 旋转已经应用过。
- 处理复核清单别自己对分数设阈值,直接遍历 data.review.flagged:每一条都把路径和理由配成一对,依据则在 cells[path].evidence 里,包括 match_ratio。
- 存储并查询用 /upload 把图像推入一张表,再用 GET /view(where、sort、select)查询它——每一行都保留带 box 和 quad 的 cells 映射,既不重跑 OCR,也不再付一次费。