同一份扫描件两次解析结果不同 / Unlimited OCR 2 的哪一步在变?

文章导读
同一张扫描件两次解析结果不同,先不要急着把问题归到“模型不稳定”上。更常见的解释是:在你看不到的流程里有一处没被固定住——可能是图像预处理的可调项取到了不同默认值,可能是批处理组合改变了页的拼装顺序,也可能是输出阶段的合并逻辑让段落或单元格顺序变了。判断方向很直接:把一张最小复现页固定下来,然后把预处理、批次组合、解码拼装这三段分别单独控制,看差异跟着哪一段走。先固定输入,再改一个变量,最后用 d
📋 目录
  1. Ⅰ 固定一张最小复现页并记录两次运行的输入快照
  2. Ⅱ 改变批处理组合看结果是否跟着变
  3. Ⅲ 把图像预处理参数写进脚本再跑对照
  4. Ⅳ 按字符和单元格做 diff 找差异落点
  5. Ⅴ 把稳定复现的参数组合固化成对照样例
A A

同一张扫描件两次解析结果不同,先不要急着把问题归到“模型不稳定”上。更常见的解释是:在你看不到的流程里有一处没被固定住——可能是图像预处理的可调项取到了不同默认值,可能是批处理组合改变了页的拼装顺序,也可能是输出阶段的合并逻辑让段落或单元格顺序变了。判断方向很直接:把一张最小复现页固定下来,然后把预处理、批次组合、解码拼装这三段分别单独控制,看差异跟着哪一段走。先固定输入,再改一个变量,最后用 diff 定位差异落点。

同一份扫描件两次结果不同,优先怀疑流程里未被固定的变量,而不是直接判定模型抖动。做法是把一张最小复现页和一组参数清单固化下来,记录文件哈希与参数值,再分别单独改变批处理组合、预处理参数,用字符 diff 和单元格坐标 diff 定位差异落点。适用边界:只有能通过配置、日志和命令复现的差异才值得继续排查;若输入哈希、参数、依赖版本三者都已固定仍不稳定,才需要往服务端侧和并发路径上找。

固定一张最小复现页并记录两次运行的输入快照

这一步的目的只有一个:排除输入文件本身在变。挑一页有代表性的扫描件——最好同时包含长文本行、一个表格和一处公式符号,因为这类页面最容易暴露差异。把它复制到一个独立目录,之后所有实验都用这份副本,不要再从原目录读取。

每次运行前生成一份输入快照,至少包含文件哈希、参数清单、代码版本三部分。文件哈希用来确认字节层面没变:

mkdir -p repro/run_a repro/run_b
cp page.png repro/page.png
sha256sum repro/page.png | tee repro/page.sha256

参数清单建议直接落成一份 JSON,和输出放在一起,避免事后回忆“当时用的是哪套参数”:

{
  "input": "repro/page.png",
  "input_sha256": "<填 sha256sum 的输出>",
  "batch_size": 1,
  "page_order": ["page.png"],
  "scale_dpi": null,
  "binarize": null,
  "deskew": null,
  "code_version": "<git rev-parse `--short` HEAD>",
  "runtime_version": "<解释器/镜像版本>"
}

跑两次,记录两次之间哪些条件确实没变:输入文件哈希相同、参数清单字段值相同、代码版本相同、运行环境相同。参数里暂时填 null 的项,说明它取的是默认值——这些正是后面要逐个钉死的对象。需要注意的是,文件哈希一致并不等于解码后的像素一致,如果两次运行之间换过图像解码库或镜像,重采样结果仍可能有细微差别,这一点需要结合环境确认。

同一份扫描件两次解析结果不同 / Unlimited OCR 2 的哪一步在变?

改变批处理组合看结果是否跟着变

同一页单独跑和多页混跑,结果不同是很常见的现象。这一组对照就是为了把“差异是否与批大小或页序有关”问清楚。

  1. 单页对照:同一份页面,批大小设为 1,连续跑两次,记录输出条数、段落顺序、表格行列数。
  2. 混跑对照:把这一页和另外两到三页拼成一个批次,页序固定,再跑一次,只观察目标页的输出。
  3. 换序对照:批次内容不变,只把目标页从第一位挪到末位,再跑一次。

每次运行都记录同一张表:批次组成、页序、目标页输出条数、块顺序是否与单页一致、表格列数是否一致。如果单页两次一致、混跑不一致,差异大概率与批次组合或共享缓冲有关;如果换序后差异跟着位置走,问题更可能出在页序拼装或结果合并阶段,而不是识别本身。反之,如果单页两次就已经不一致,那么批次可以暂时排除,重点转向预处理和解码。

把图像预处理参数写进脚本再跑对照

预处理阶段最常见的坑是可调项没写死。缩放分辨率、灰度化、二值化阈值、去噪核、自动纠偏这些环节,如果调用时留空,取到的默认值可能随版本变化,也可能带随机性。做法是把它们全部显式写进一个对照脚本,一次只松开一项。

下面是通用骨架,函数名和参数名只是占位,具体调用以你所使用仓库的实际接口为准;没有在仓库里确认过的字段,不要当成既定接口直接写死。

同一份扫描件两次解析结果不同 / Unlimited OCR 2 的哪一步在变?
from pathlib import Path
from PIL import Image
import json

CFG = json.loads(Path("repro/params.json").read_text())

def preprocess(path, cfg):
    img = Image.open(path)
    if cfg.get("force_gray"):
        img = img.convert("L")
    if cfg.get("target_dpi"):
        # 以原始尺寸和 dpi 为基准换算目标尺寸,此处按实际库能力实现
        pass
    if cfg.get("binarize_threshold") is not None:
        img = img.point(lambda p: 255 if p > cfg["binarize_threshold"] else 0)
    # deskew / denoise 等项按仓库实际能力决定是否启用
    return img

out = Path("repro/run_a")
out.mkdir(exist_ok=True)
img = preprocess(CFG["input"], CFG)
img.save(out / "preprocessed.png")

把预处理后的图也存盘,这一步很关键:如果两次运行的预处理输出图像本身就有差异,用肉眼或哈希一比对就能看出来,后面的解析差异就不必再往模型上找。逐项固定时,建议的顺序是先固定缩放和灰度,再固定二值化,最后处理纠偏这类带估计性质的步骤。每固定一项就跑一次,只记录目标页的输出是否变化。

按字符和单元格做 diff 找差异落点

“结果不同”太笼统,要变成具体位置才可处理。建议分两层比对:先比文本流的字符差异,再比表格结构的坐标差异。

import difflib
from pathlib import Path

a = Path("repro/run_a/out.txt").read_text().splitlines()
b = Path("repro/run_b/out.txt").read_text().splitlines()

for line in difflib.unified_diff(a, b, fromfile="a", tofile="b", lineterm=""):
    print(line)

字符层比对关注三件事:差异是整行缺失、单字替换,还是顺序调换。整行缺失通常指向段落切分或合并;单字替换多与清晰度、二值化阈值相关;顺序调换则更可能是拼装阶段的问题。表格部分单独处理,把每个输出单元格记成(页号,行号,列号,文本)四元组,按坐标对齐后再比文本,这样能直接看出是行列数变了,还是单元格内容串了位。

同一份扫描件两次解析结果不同 / Unlimited OCR 2 的哪一步在变?

记录时按差异类型归类:集中在长行、集中在公式符号、还是集中在表格单元格。三类落点对应的排查方向不同——长行常与换行和宽度估计有关,公式常与字符集和符号映射有关,表格单元格常与坐标排序和合并逻辑有关。归类清楚之后,前面几节的对照实验才有明确的观察目标。

把稳定复现的参数组合固化成对照样例

定位出稳定组合之后,别停在“这次好了”。把能稳定复现的那一组固化成回归样例,后续每次改动都重跑一遍。

samples/
  page.png              # 固定输入副本
  params.json           # 固定参数清单
  expected/out.txt      # 预期文本输出
  expected/cells.tsv    # 预期单元格四元组
  run.sh                # 一键重跑 + diff 脚本

样例里要写清楚三样东西:固定输入的哈希、固定参数的完整清单、预期输出的比对方式。每次改动预处理代码、批次策略或输出拼装后,跑一次 run.sh,把实际输出与 expected 目录比对;差异为空即视为未引入回归,出现差异则先看落点属于长行、公式还是表格,再回到对应小节定位。

需要留一条边界:预期输出应当是你在自己环境里实际跑出来并确认过的结果,不要照抄别处的输出;如果某一页在两种参数下都能得到合理但不同的结果,就把它记成两套样例并注明适用条件,而不是强行统一。这样这组样例才能长期作为判断依据,而不是变成又一个说不清的默认值。