先别问 HyOCR-1.5 的分数字不值,先问自家票据上现在哪个字段在错、错在什么图像条件下。可行的做法是:抽一批有代表性的自家票据,整理成带真值的样本集,用批量脚本各跑一次新旧两条链路,把结果落到字段级做比对,再看短板集中在哪类图像上。能救回来的字段就接,救不回来的字段就别为了换模型而换。这一步的工作量主要在样本整理和标注,不在模型调用本身;样本量不需要多,但必须覆盖你真实会遇到的图像类型,否则结论只对实验室干净图成立。
判断 HyOCR-1.5 是否值得接入,建议用自家票据做一次字段级对照:正常、歪斜、反光三类各取样,带真值跑批量推理,按字段统计新旧方案各自的错处。字段上能明确补位的,走灰度路由;短板落在图像质量本身、两边都错的,应优先修前处理而不是换模型。样本量、阈值和回退条件都需结合自身数据量和业务容忍度确定。
整理自家票据样本,覆盖正常、歪斜、反光三类
分类口径要按“影响识别结果的图像条件”来定,而不是按票据业务类型。通常先分三类:正常(平整、正向、光照均匀)、歪斜(拍摄角度明显偏、轻微褶皱导致透视变形)、反光(有高光斑、局部过曝或压暗)。业务类型(增值税票、行程单、收据等)作为二级标签挂在文件名里,方便后面交叉看某类票据是否整体偏弱。每类样本量按自身数据量决定:如果历史存量只有几十张,就每类尽量覆盖到你能拿到的上限,并明确记录“该类样本不足,结论仅供方向参考”;如果存量充足,建议每类先取几十到一百张,够看出字段级倾向即可,不必一次铺大。
命名规则建议一行一文件、信息可解析,例如 cls-normal_inv_vat_0001.jpg,其中 cls 段取值 normal/skew/glare,第二段是票据类型,末段是序号。歪斜和反光的判类由整理人肉眼看图决定,判不准的单独放 cls-uncertain,别硬塞进三类里污染统计。真值另存 CSV,字段名与后面的脚本保持一致,避免比对时再做一次映射。
写批量推理脚本,把输出落成结构化字段
脚本的作用是把“模型说了什么”固定成可比对的结构,放在哪里执行都可以,只要能读到样本目录、写出结果目录。下面是一个可替换调用的骨架,infer 里换成你实际的调用方式(HTTP 请求、命令行或本地推理),输出字段名统一成同一套。
import json, pathlib
IN = pathlib.Path("samples") # 待评样本目录
OUT = pathlib.Path("out"); OUT.mkdir(exist_ok=True)
fail_fp = open(OUT / "failed.jsonl", "w", encoding="utf-8")
def infer(image_path):
# 替换成实际调用,返回 dict
# 约定键:invoice_no / date / amount / tax / raw_text
...
for p in sorted(IN.rglob("*.jpg")):
rel = str(p.relative_to(IN))
try:
r = infer(p)
rec = {"file": rel, "ok": True,
"fields": {k: r.get(k) for k in
("invoice_no", "date", "amount", "tax")},
"raw_text": r.get("raw_text", "")}
except Exception as e:
fail_fp.write(json.dumps(
{"file": rel, "error": repr(e)}, ensure_ascii=False) + "\n")
continue
with open(OUT / "result.jsonl", "a", encoding="utf-8") as f:
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
fail_fp.close()输出用 JSONL 比整包 JSON 好追加,单条记录形如 {"file":"cls-glare_inv-vat_0007.jpg","ok":true,"fields":{"invoice_no":"...","date":"2024-05-11","amount":"128.00","tax":"7.25"},"raw_text":"..."}。失败样本不混进主结果,单独写 failed.jsonl,记录文件名和错误原因;跑完先看这个文件的行数,如果失败集中在某一类图像上,往往说明是前处理或格式问题,而不是模型不行。同一份样本对现有 OCR 再跑一遍,输出结构保持完全一致,后面才能逐字段比。
和现有 OCR 结果逐字段比对,标出各自对错
比对脚本读两份结果 JSONL 和一份真值 CSV,输出一条一行的差异表。关键是归一化规则要先写死,否则大小写、空格、日期格式、金额千分位会制造大量假差异。
import json, csv
def norm(v):
if v is None: return ""
return str(v).strip().replace(" ", "").replace(",", "").upper()
GT = {} # gt.csv: file,field,label
with open("gt.csv", encoding="utf-8") as f:
for row in csv.DictReader(f):
GT[(row["file"], row["field"])] = norm(row["label"])
def load(path):
m = {}
with open(path, encoding="utf-8") as f:
for line in f:
r = json.loads(line)
for k, v in r["fields"].items():
m[(r["file"], k)] = norm(v)
return m
new, old = load("out/new.jsonl"), load("out/old.jsonl")
for key, truth in GT.items():
a, b = new.get(key), old.get(key)
tag = ("both_ok" if a == truth and b == truth else
"new_only_ok" if a == truth else
"old_only_ok" if b == truth else
"both_wrong" if a and b else "empty")
print(key[0], key[1], tag, truth, a, b, sep="\t")差异标记规则建议只留五档:两边都对、仅新模型对、仅旧模型对、两边都错、至少一方为空。人工复核用分层抽样:new_only_ok 和 old_only_ok 全看,这是结论的直接证据;both_ok 每类图像抽几张确认没标错真值;both_wrong 优先看数量最多的那一类图像。复核时不改真值之外的任何东西,发现标注错就回填 gt.csv 重跑。
按字段类型统计差异,找出短板集中在哪类图像
把逐条差异按“字段 × 图像类别”折叠成一张计数表,表里只放条数,不放任何推算出来的比例结论。
| 字段 | 图像类别 | 两边都对 | 仅新模型对 | 仅旧模型对 | 两边都错 | 空值 |
|---|---|---|---|---|---|---|
| 发票号 | normal | |||||
| 金额 | glare | |||||
| 开票日期 | skew |
短板归因按这个顺序判定:第一,看该单元格里“两边都错”是否占多数,是则问题更可能在前处理或采集环节,换模型收益有限;第二,看“仅新模型对”是否集中在某类图像,是则新模型在这类条件下的字段抽取上确实有补位;第三,看“仅旧模型对”是否集中在某几个字段,是则这些字段不宜整体切走;第四,如果同一字段在 normal 类都出错,先怀疑字段定义与真值标注口径不一致。需要回看原图的条件包括:差异集中在同一张或同一批票、字段整列输出为空、raw_text 出现乱码、税额与金额不一致到不可能。
把结论写成接入条件而不是笼统打分
条件清单按“字段 + 图像类别 + 判定方式”写,一条一个可执行动作,示例:
- 金额字段:在 normal 与 skew 类图像上,仅新模型对的条数多于仅旧模型对的条数,且两边都错的条数未增加,则该字段可路由到 HyOCR-1.5。
- 开票日期字段:在 glare 类图像上两边都错占多数,则该类图像维持现有方案,先修反光处理再重跑比对。
- 发票号字段:出现“仅旧模型对”的情况,则保留原链路输出,新模型结果只作辅助展示,不覆盖落库值。
灰度接入时观察这几项,用日志说话:各字段空值条数、人工驳回条数、单票处理耗时、失败样本条数、raw_text 明显异常的条数。触发回退的条件建议提前写清:某字段空值条数连续多票上升、人工驳回集中指向新模型输出的字段、单票耗时超出可接受预算、调用失败率异常。回退动作要做成配置开关,一条配置切回原链路,同时保留双跑日志用于复盘。任何阈值都需结合自身业务容忍度和运行环境确认,跑几轮再定,不要一次定死。