OvisOCR2 管版面还原、后处理脚本管字段校验,中间留一层人工抽检

文章导读
OvisOCR2 负责把扫描件还原成带版面结构的块,但结构化之后的字段能不能直接进业务库,要分两步看:这个字段在解析结果里有没有稳定的来源块,以及它的对错能不能用规则判定。比较稳的分法是,模型输出只到“文本 + 块类型 + 坐标”为止,格式类的判断交给后处理脚本挡在入库之前,语义上的对错交给一层人工抽检。这三层各管一段,不要让脚本去猜语义,也不要让人工去补格式。
📋 目录
  1. A 先定模型输出到业务字段的映射
  2. B 写字段校验脚本
  3. C 设一层人工抽检
  4. D 把校验失败回流成样本
A A

OvisOCR2 负责把扫描件还原成带版面结构的块,但结构化之后的字段能不能直接进业务库,要分两步看:这个字段在解析结果里有没有稳定的来源块,以及它的对错能不能用规则判定。比较稳的分法是,模型输出只到“文本 + 块类型 + 坐标”为止,格式类的判断交给后处理脚本挡在入库之前,语义上的对错交给一层人工抽检。这三层各管一段,不要让脚本去猜语义,也不要让人工去补格式。

适用场景是单据类文档批量结构化后写业务表。操作上先做字段映射表,标出没有来源块的字段;再写只做格式校验的脚本放在入库前;脚本判不了的语义问题由抽检兜住,触发条件写进配置。验证方式是拿一批固定样本反复跑,看失败项是否收敛。边界是:抽检只覆盖被触发的那部分,未触发的语义错误,脚本同样看不见。

先定模型输出到业务字段的映射

模型边界划在“还原”为止:它给出每个块的文本、类型(标题、键值对、表格单元格、页脚段落)和坐标,不判断这个值在业务上是否合理。业务需要的值,要逐个在解析结果里找落点,找不到的要主动标出来,而不是让脚本去文本里猜。

业务字段解析结果中的来源块是否有对应块
单据号键值对块 key 命中,或页眉文本正则有,位置不固定
开票日期键值对块的 value有
金额合计表格末行或“合计”锚点行有
明细行表格主体行,按表头列名映射有,列序需校正
销方、购方名称键值对块,或标题附近文本块有,多来源需定优先级
币种金额字符串中的符号无独立块,需派生
备注页脚段落块有,非结构化
业务系统客户编码—无,按名称查档案
审批状态—无,来自业务系统

表里“无对应块”的两行要单独处理。业务系统客户编码在解析结果里没有对应块,只能拿购方名称去档案库做匹配,匹配不上就留空并打标记,不要用模型文本硬凑一个编码。审批状态本来就属于业务系统自身的数据,和文档内容无关,不该放进模型的输出范围。

有对应块但位置不固定的字段(单据号、日期),映射时要写清楚定位顺序:先找键值对里 key 命中的项,找不到再退回页眉区域的正则,两条都不中就当空值处理。币种这类派生字段在映射表里单独标注,由脚本按金额字符串解析,不指望模型单独给出一个块。

OvisOCR2 管版面还原、后处理脚本管字段校验,中间留一层人工抽检

写字段校验脚本

脚本放在模型输出落库之后、写业务表之前,输入是解析结果的 JSON 记录,输出是失败列表。它只做格式判断,不做业务推理:空值、类型、枚举、长度四类规则能覆盖大部分入库前的低级错误。规则用配置描述,换字段不用改代码。

import decimal, datetime

RULES = {
    ‘doc_no’:     {‘required’: True,  ‘type’: ‘str’,     ‘max_len’: 32},
    ‘issue_date’: {‘required’: True,  ‘type’: ‘date’,    ‘format’: ‘%Y-%m-%d’},
    ‘total’:      {‘required’: True,  ‘type’: ‘decimal’},
    ‘currency’:   {‘required’: False, ‘enum’: [‘CNY’, ‘USD’, ‘EUR’]},
    ‘buyer’:      {‘required’: True,  ‘type’: ‘str’,     ‘max_len’: 64},
}

def validate(record, rules):
    errors = []
    for field, rule in rules.items():
        value = record.get(field)
        text = ‘’ if value is None else str(value).strip()
        if rule.get(‘required’) and text == ‘’:
            errors.append({‘field’: field, ‘rule’: ‘required’, ‘value’: value})
            continue
        if text == ‘’:
            continue
        if rule.get(‘type’) == ‘decimal’:
            try:
                decimal.Decimal(text.replace(‘,’, ‘’))
            except decimal.InvalidOperation:
                errors.append({‘field’: field, ‘rule’: ‘type:decimal’, ‘value’: value})
                continue
        elif rule.get(‘type’) == ‘date’:
            try:
                datetime.datetime.strptime(text, rule.get(‘format’, ‘%Y-%m-%d’))
            except ValueError:
                errors.append({‘field’: field, ‘rule’: ‘type:date’, ‘value’: value})
                continue
        if ‘enum’ in rule and text not in rule[‘enum’]:
            errors.append({‘field’: field, ‘rule’: ‘enum’, ‘value’: value})
        if ‘max_len’ in rule and len(text) > rule[‘max_len’]:
            errors.append({‘field’: field, ‘rule’: ‘max_len’, ‘value’: value})
    return errors

def report(record_id, errors):
    if not errors:
        return True
    for e in errors:
        print(‘[REJECT] record={} field={} rule={} value={!r}’.format(
            record_id, e[‘field’], e[‘rule’], e[‘value’]))
    return False

失败原因按行打印,一条错误一行,带记录 ID、字段名、命中的规则和原始值,方便直接 grep 日志。原始值不要截断,后面回流样本要用它复现问题。

设一层人工抽检

脚本判断不了的是语义:金额格式对但和明细加总不一致、客户名称写成了别家、同一张单据跨页时合计算了两遍。这类问题只能靠人看,但抽检要有触发条件,不能凭感觉抽样。

  • 字段为空:required 规则未覆盖但业务上必填的字段,或映射表里标注“无对应块”的字段最终留空。
  • 格式不符:校验脚本返回任意一条错误,记录进抽检队列,而不是直接丢弃。
  • 同一单号多页结果冲突:按单号分组后,比较各页的合计金额、日期、销方名称,任一不一致就整组进抽检;只在各页都取到该字段时才比较,缺失的页不算冲突。
def need_review(errors, same_doc_records):
    if errors:
        return ‘format_or_empty’
    for field in (‘total’, ‘issue_date’, ‘seller’):
        values = {r.get(field) for r in same_doc_records if r.get(field)}
        if len(values) > 1:
            return ‘cross_page_conflict:’ + field
    return None

抽检结果要落回记录上:标记通过,或写入修正后的值,修正值单独存一列,不覆盖模型原始输出。抽检比例和队列积压上限结合单据量和可投入的人力定,先按当前量跑一段时间再调,不要在没数据支撑时直接拍一个比例。

OvisOCR2 管版面还原、后处理脚本管字段校验,中间留一层人工抽检

把校验失败回流成样本

每次失败都存成一个可复跑的样本目录,而不是只留一行日志。样本至少要存这些字段:

  • sample_id:时间戳加记录 ID,保证可排序
  • file_hash:原始文件哈希,用来识别同一份文件反复失败
  • model_version、rule_version:哪套模型输出和哪套校验规则跑出来的
  • raw_parse:模型原始解析结果 JSON,不做裁剪
  • failed_rules:命中的字段与规则名
  • expected:人工确认后的正确值,抽检前可以为空
  • source:来自脚本拒绝还是人工抽检
samples/
  20240601-1032-rd8801/
    raw_parse.json
    meta.json
    expected.json
    origin.pdf

复跑时按目录遍历,同一批样本每次全量校验,输出到一份新的报告文件,不要覆盖上一轮结果:

python -m ocr_validate replay `--samples` samples/ `--rules` rules.yaml `--out` report/replay-001.jsonl

改规则或换模型版本时各跑一遍,对比两次报告里新增和消失的失败字段,新增项单独列出来看,避免改一处规则带出另一处退化。expected 为空的样本只验证流程能否跑通、不做对错判定,人工补完正确值后再纳入对比,这样同一批样本才能反复验证。