同一张扫描件两次解析结果不同,先不要急着把问题归到“模型不稳定”上。更常见的解释是:在你看不到的流程里有一处没被固定住——可能是图像预处理的可调项取到了不同默认值,可能是批处理组合改变了页的拼装顺序,也可能是输出阶段的合并逻辑让段落或单元格顺序变了。判断方向很直接:把一张最小复现页固定下来,然后把预处理、批次组合、解码拼装这三段分别单独控制,看差异跟着哪一段走。先固定输入,再改一个变量,最后用 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 的项,说明它取的是默认值——这些正是后面要逐个钉死的对象。需要注意的是,文件哈希一致并不等于解码后的像素一致,如果两次运行之间换过图像解码库或镜像,重采样结果仍可能有细微差别,这一点需要结合环境确认。
改变批处理组合看结果是否跟着变
同一页单独跑和多页混跑,结果不同是很常见的现象。这一组对照就是为了把“差异是否与批大小或页序有关”问清楚。
- 单页对照:同一份页面,批大小设为 1,连续跑两次,记录输出条数、段落顺序、表格行列数。
- 混跑对照:把这一页和另外两到三页拼成一个批次,页序固定,再跑一次,只观察目标页的输出。
- 换序对照:批次内容不变,只把目标页从第一位挪到末位,再跑一次。
每次运行都记录同一张表:批次组成、页序、目标页输出条数、块顺序是否与单页一致、表格列数是否一致。如果单页两次一致、混跑不一致,差异大概率与批次组合或共享缓冲有关;如果换序后差异跟着位置走,问题更可能出在页序拼装或结果合并阶段,而不是识别本身。反之,如果单页两次就已经不一致,那么批次可以暂时排除,重点转向预处理和解码。
把图像预处理参数写进脚本再跑对照
预处理阶段最常见的坑是可调项没写死。缩放分辨率、灰度化、二值化阈值、去噪核、自动纠偏这些环节,如果调用时留空,取到的默认值可能随版本变化,也可能带随机性。做法是把它们全部显式写进一个对照脚本,一次只松开一项。
下面是通用骨架,函数名和参数名只是占位,具体调用以你所使用仓库的实际接口为准;没有在仓库里确认过的字段,不要当成既定接口直接写死。
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)
字符层比对关注三件事:差异是整行缺失、单字替换,还是顺序调换。整行缺失通常指向段落切分或合并;单字替换多与清晰度、二值化阈值相关;顺序调换则更可能是拼装阶段的问题。表格部分单独处理,把每个输出单元格记成(页号,行号,列号,文本)四元组,按坐标对齐后再比文本,这样能直接看出是行列数变了,还是单元格内容串了位。
记录时按差异类型归类:集中在长行、集中在公式符号、还是集中在表格单元格。三类落点对应的排查方向不同——长行常与换行和宽度估计有关,公式常与字符集和符号映射有关,表格单元格常与坐标排序和合并逻辑有关。归类清楚之后,前面几节的对照实验才有明确的观察目标。
把稳定复现的参数组合固化成对照样例
定位出稳定组合之后,别停在“这次好了”。把能稳定复现的那一组固化成回归样例,后续每次改动都重跑一遍。
samples/
page.png # 固定输入副本
params.json # 固定参数清单
expected/out.txt # 预期文本输出
expected/cells.tsv # 预期单元格四元组
run.sh # 一键重跑 + diff 脚本
样例里要写清楚三样东西:固定输入的哈希、固定参数的完整清单、预期输出的比对方式。每次改动预处理代码、批次策略或输出拼装后,跑一次 run.sh,把实际输出与 expected 目录比对;差异为空即视为未引入回归,出现差异则先看落点属于长行、公式还是表格,再回到对应小节定位。
需要留一条边界:预期输出应当是你在自己环境里实际跑出来并确认过的结果,不要照抄别处的输出;如果某一页在两种参数下都能得到合理但不同的结果,就把它记成两套样例并注明适用条件,而不是强行统一。这样这组样例才能长期作为判断依据,而不是变成又一个说不清的默认值。