在发票、合同、报表里提字段,难点从来不是"模型看不看得懂字",而是"它有没有看对地方"。长截图和 PDF 恰好是最容易让它看错地方的输入:一张 2500×14000 的电商订单长图,或者一份 30 页的扫描合同,直接丢进去,返回的 JSON 语法完全合法,数字却串了行、漏了页。下面按排错的路子走一遍。
这里说的"引导取景(guided vision)",指的是用缩略图导航、坐标框选、局部裁剪放大这一套动作,把模型的注意力强行限制在指定区域,再配合分步追问拿到结构化结果。具体的功能命名、参数与可用范围,以官方文档当前版本为准。
报错现象
典型场景:要把一批发票长截图和 PDF 合同里的「发票号 / 开票日期 / 价税合计 / 购买方 / 销售方 / 税率」提成一张表。
出错时的样子通常是这几类:
1. 接口直接报错拒收。 返回信息类似 image dimensions too large、request payload size exceeds limit、invalid image。影响范围是整批文件全军覆没,最容易发现。
2. JSON 解析失败。 拿到的是 json.decoder.JSONDecodeError: Expecting ',' delimiter,因为返回里混了 ``` `json `` 代码块围栏、// 注释、中文全角引号 “2024年”`,或者末尾跟了一句"以上信息仅供参考"。
3. 格式合法但内容错位。 这是最要命的一类:发票号那格填的是对方公司名,价税合计那格拿到的是税额,10 行的明细表只回了 6 行。因为 JSON 本身能解析,下游直接入库,错误要等对账时才被发现。
4. 坐标框选失效。 让人工指定"只看下半部分",或者让模型返回区域坐标,结果裁出来的图和想看的区域差了一整行,或者框在了空白处。
5. PDF 只读了第一页。 30 页的合同只返回第 1 页的字段,其余静默丢失,没有报错。
6. 多页字段互相污染。 第 3 页的金额配了第 5 页的合同编号,合并成一条记录。
影响范围从"跑不通"到"跑通了但数据是错的"都有,后者更危险。
可能原因
按出现概率从高到低:
1. 图像被缩放降采样,小字糊了。 长截图为了塞进模型输入,长边被压缩,正文 12px 的字直接糊成一团,发票号里 0 和 O、1 和 l 分不出来。
2. 没有给注意力锚点。 整图一次性丢进去,模型做的是平铺扫描;表格列一多,列边界就串了。
3. 一次问太多。 一张图里同时要 20 个字段 + 明细表格 + 印章信息,注意力被平均分配,长尾字段必漏。
4. 坐标约定不一致。 人理解的"下半部分"和模型输出的像素值对不上;归一化坐标(0–1)和绝对像素坐标混用;原点在左上还是左下没说。
5. PDF 处理链路的问题。 只渲染了第一页;渲染 DPI 太低;扫描件没有文字层;PDF 本身加密,渲染出来是空图。
6. 输出格式约束太弱。 只说"给我 JSON",没有给 schema、没有禁止代码块和注释、没有规定缺失值怎么写。
7. 图片方向或透视不正。 手机拍屏、斜着拍,文本行是斜的,模型读行时错行。
8. 多页拼在一起调用。 一次塞 5 页进同一个请求,页与页之间没有隔离标记。
逐条排查与解决
第 1 条:先量分辨率,再决定要不要切片
判断方法:
```bash
python -c "from PIL import Image; im=Image.open('fapiao.png'); print(im.size)"
```
如果长边明显超过模型稳态识别的尺寸(具体上限以官方文档当前版本为准),就必须切片。切片要带重叠,否则会把一行字从中间切断:
```python
from PIL import Image
im = Image.open("long.png")
w, h = im.size
tile_h = 1600 # 每片高度
overlap = 120 # 重叠像素,防止切断文本行
for i in range(0, h, tile_h):
top = max(0, i - overlap)
bottom = min(h, i + tile_h + overlap)
im.crop((0, top, w, bottom)).save(f"tile_{i:06d}.png")
```
判断是不是这个原因:把切片出来的图打开看一眼,如果原来糊的发票号变清楚了,就是这个原因。
第 2 条:用"缩略图导航 → 框选 → 裁高清重看"做引导取景
不要让模型一次看全图。分两步走。
第一步,给它一张压小的整页缩略图,只让它干一件事——找区域:
```text
这是一整页文档的缩略图。请只做一件事:找出「价税合计」这一项所在的矩形区域。
返回如下 JSON,不要输出任何其他文字:
{"box": [x1, y1, x2, y2]}
坐标是相对整图的归一化值,范围 0 到 1,原点在左上角,x 向右,y 向下,
x1<x2,y1<y2,框要完整包住这一行文字(含左边的标签和右边的金额)。
```
第二步,按返回的框裁原图(用高清原图,不要用缩略图):
```python
from PIL import Image
im = Image.open("fapiao.png")
w, h = im.size
x1, y1, x2, y2 = box # 模型返回的归一化坐标
四周留一点余量,避免把字的边缘切掉
pad_x, pad_y = 0.01, 0.01
crop = im.crop((
int(max(0, x1 - pad_x) * w),
int(max(0, y1 - pad_y) * h),
int(min(1, x2 + pad_x) * w),
int(min(1, y2 + pad_y) * h),
))
crop.save("roi.png")
```
第三步,把框画回缩略图,人眼确认一次再往下走:
```python
from PIL import ImageDraw
vis = im.copy()
d = ImageDraw.Draw(vis)
d.rectangle([x1 * w, y1 * h, x2 * w, y2 * h], outline="red", width=6)
vis.save("check_box.png")
```
这一步是整个流程里性价比最高的动作。check_box.png 一看就知道模型有没有看对地方,比盯着 JSON 猜快得多。
判断是不是这个原因:如果模型返回的 box 明显偏了(比如框在了表头而不是合计行),说明缩略图太小看不清,把缩略图长边调大一点再试,或者手动给坐标。
第 3 条:分步追问,一轮只干一件事
把一次性提问拆成四轮:
- 第一轮:只看版面,输出这是什么类型的文档、有哪些区块(表头区 / 明细表区 / 合计区 / 印章区)。
- 第二轮:针对每个区块,只输出字段名清单,不输出值。
- 第三轮:逐个字段取值,每个值必须附上"原图里的原文片段"作为证据。
- 第四轮:只针对前几轮里对不上的字段复核一次。
第三轮的输出模板可以这样约束:
```text
请只输出 JSON,不要代码块围栏,不要注释,不要任何解释文字。
每个字段必须给出 value 和 evidence:
- value:字段的最终取值,识别不到就写 null,不要猜测
- evidence:该值在图中对应的原文片段,逐字照抄
- page:来自第几页,单页图写 1
- confidence:high / medium / low
格式:
{"fields": [{"name": "...", "value": "...", "evidence": "...", "page": 1, "confidence": "high"}]}
```
判断是不是这个原因:如果一次性提问漏了 4 个字段,拆成四轮后漏的字段补回来了,就是注意力被平均分配的问题。
第 4 条:把坐标约定写死
在提示词里明确三件事:归一化还是像素、原点在哪个角、边界是否包含。能给出一个输入输出示例最好。如果模型连续两轮返回的坐标都对不上,直接放弃坐标方案,改成"让人工给出像素值"或者"用固定的版面切分比例",别在坐标上耗。
第 5 条:检查 PDF 链路
```bash
pdfinfo contract.pdf # 看页数、是否加密
pdftoppm -r 220 -png contract.pdf page # 逐页渲染成图
```
要点:
- DPI 建议 200 到 300 之间。72 DPI 渲染出来的小字基本没法用。
- 扫描件没有文字层的,先过一遍 OCR 工具生成文字层,再渲染。
- 加密 PDF 先用
qpdf --decrypt之类的方式解密,否则渲染出来是白图。 - 每次请求只送一页,并且把页码写进输出结构里。
判断是不是这个原因:ls page-*.png | wc -l 的数目和 pdfinfo 报的页数对不上,就是只渲染了部分页。
第 6 条:输出被污染导致解析失败
解析前先把原始返回打印出来看,别急着 json.loads。常见补救是抽取第一个完整的 JSON 对象:
```python
import json
def extract_json(text):
start = text.find("{")
if start == -1:
return None
depth = 0
in_str = False
esc = False
for i in range(start, len(text)):
ch = text[i]
if in_str:
if esc:
esc = False
elif ch == "\\":
esc = True
elif ch == '"':
in_str = False
else:
if ch == '"':
in_str = True
elif ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
try:
return json.loads(text[start:i + 1])
except json.JSONDecodeError:
return None
return None
```
判断是不是这个原因:日志里打印出来的原文里有代码块围栏或解释性文字。
第 7 条:方向与透视
```bash
exiftool -Orientation fapiao.jpg
```
```python
from PIL import ImageOps
im = ImageOps.exif_transpose(im)
```
手机斜拍的,先做一次旋转校正;透视变形严重、文本行明显歪斜的,建议直接重拍或用扫描类应用重新生成,比在模型侧想办法划算。
第 8 条:多页隔离
每页单独调用,输出里强制带 page 和 file 字段;合并阶段先做冲突检查——同一个合同编号在不同页出现不同值时,直接进人工队列,不要静默取最后一个。
都不管用时的兜底方案
- 降级到"人机协同"。 让模型只负责框出可疑字段的位置,数字由人来填。核对 5 个框比核对一份全错的表快。
- 换链路:先 OCR 后抽取。 扫描件质量差的时候,先用 OCR 或表格识别工具产出文本层,再把纯文本交给模型做结构化。文本抽取的字段稳定性通常高于直接看图,代价是丢掉版面信息。
- 拆得更碎。 把 20 个字段拆成 5 次提问,每次只问 4 个。调用次数上去了,但准确率往往能拉回来。
- 两次交叉验证。 用两个不同措辞的提示词各跑一遍,值不一致的字段全部标记出来交人工。
- 局部增强重问。 把可疑区域放大 2 到 3 倍、做一次灰度化和对比度增强,再问一次。
- 兜底落地方式。 无论如何先把结果落成 CSV,人工只改差异行,而不是从头重录。
如何预防再次发生
- 建回归样例集。 攒 10 到 20 个文件,覆盖长截图、扫描件、斜拍、多页 PDF、带印章遮挡的,每次改提示词都跑一遍,统计字段级准确率,而不是"看着好像对"。
- 强制 evidence。 每个值都必须带原文片段和位置,没有 evidence 的值直接打标记。这条能挡掉大部分编造。
- 做格式校验闸门。 金额、日期、编号在入库前过一遍解析:
```python
import re
from datetime import datetime
from decimal import Decimal, InvalidOperation
def check_amount(s):
if s is None:
return None
try:
return Decimal(s.replace(",", "").replace("¥", "").replace("¥", "").strip())
except InvalidOperation:
return None
def check_date(s, fmts=("%Y年%m月%d日", "%Y-%m-%d", "%Y/%m/%d")):
for f in fmts:
try:
return datetime.strptime(s.strip(), f)
except (ValueError, AttributeError):
continue
return None
def check_invoice_no(s):
if not s:
return False
return bool(re.fullmatch(r"[A-Z0-9]{8,20}", s.replace(" ", "").upper()))
```
校验不过的进人工队列,不要往库里写。
- 输出里带溯源字段。
schema_version、file、page、prompt_id都写进结果,出问题时能回溯到是哪一版提示词、哪一页。 - 参数进配置。 切片高度、重叠像素、渲染 DPI、缩略图长边,全部写进配置文件,别散落在脚本各处,方便调参和复现。
- 日志留全。 原图、切片、提示词、原始返回都存下来,出错时能离线重放。
- 心态上把模型输出当草稿。 结构化提取的上限不是模型能力,而是校验环节做得够不够。入库前有一道校验闸门,比换一个更强的模型有用得多。
