深入剖析扫描版PDF翻译的技术难点:需用inpainting模型擦除原文、大模型重排版、以及处理签名线等保留元素。提供了可落地的图像处理方案。
每一款"翻译 PDF"工具在处理扫描件之前都表现得很漂亮。
一旦你喂给它一份扫描件,收回来的就是一大坨重新排版的文字。表格没了。印章飘在段落之上。双栏布局变成了单栏。对于数字 PDF 不会出现这种情况,因为文件中本身就有文字,可以直接在原位替换。但扫描件没有文字——只有看起来像文字的像素。
我花了不少时间搭建了一套处理流水线,令人意外的是,翻译本身只占极小一部分工作。翻译就是一个 API 调用。难点全在图形处理上:
从位图中擦除原有墨迹,同时不破坏下面的内容。
将翻译后的文字重新排版进原文字留下的空间——通常翻译后的文字比原文更长。
以上两步还要足够快,否则处理一份长文档就成了喝咖啡休息。
以下是大致涉及的内容。
要擦除文字,你需要用到图像修复模型(我用的是 LaMa)以及一个二值蒙版,标明要移除哪些像素。蒙版决定了所有质量。
最直接的蒙版是 OCR 边界框,填满它。别这么做。官方文档恰恰是那种边界框里装满了你想要保留的东西的情况:表格线、签名行下划线、印章边缘、背景纹理。把框填满,这些全都擦掉了,修复模型会兴高采烈地在原处凭空补出一片空白纸。
更好的做法是只蒙住墨迹部分。在框内做 Otsu 阈值分割,取深色的那个类别:
roi = cv2.cvtColor(img_bgr[y0:y1, x0:x1], cv2.COLOR_BGR2GRAY)
_, strokes = cv2.threshold(roi, 0, 255,
cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
这在白底黑字的标题上会失效——Otsu 不知道哪个类别才是墨迹。修复方法很便宜,而且一直很可靠:文本边界框的边缘几乎一定是背景,所以如果边缘大部分落在了"笔画"类别,就翻转它。
border = np.concatenate(
[strokes[0, :], strokes[-1, :], strokes[:, 0], strokes[:, -1]]
)
if border.mean() > 127: # light text on dark background
strokes = 255 - strokes
然后做一点膨胀,让反锯齿边缘也被纳入。残留的反锯齿会显示为原单词的灰色残影,看起来比稍微大一点的蒙版更糟糕。
还有一件事值得做:不要把整页送到 GPU 上。把相近的框合并,为每组裁剪出带上下文边距的区域,然后对裁剪区域进行修复。LaMa 需要周围上下文来重建纹理,但只需要局部上下文——适度的边距和整页效果一样好,而且传输的数据量只是零头。
德语很长。西班牙语相对于英语也很长。翻译后的段落经常比原文长 30%,而它必须塞回一个固定大小的框里。
你还丢失了换行符——OCR 给你的是文字,不是原来的换行方式,所以你得重新换行,而换行取决于你选择的字号。这是一个没有解析解的二维拟合问题。二分查找:
def max_fit_scale(item) -> float:
if fits(item, 1.0):
return 1.0
lo, hi = FLOOR, 1.0
if not fits(item, lo):
return lo # even the floor overflows
for _ in range(12):
mid = (lo + hi) / 2
if fits(item, mid):
lo = mid
else:
hi = mid
return lo
fits() 是在一个临时页面上对真实换行的干跑测试,所以测试和渲染不可能出现不一致。这听起来很明显,但我一开始没这么做;有一个廉价的近似 fits() 函数产生了一类 bug——拟合搜索很有信心,但渲染器还是溢出了。
两点经验之谈:
按段落缩小,而不是按行缩小。如果每行都各自找最佳比例,那么假设某段落第三行恰好比较密集,第三行就会比第二行和第四行渲染得更小。每行各自都是合适的,但整体读起来就是残缺的。
保留一个下限,把触达下限视为信号,而不是降级处理。如果某个区域即使缩小到一半都放不下,这通常不是拟合问题——而是 OCR 在上游把两个文本块合并了。缩到看不清只会掩盖真正的 bug。
每一页的阶段是:分析(本地,快速)、翻译(LLM API)、修复(GPU API)、渲染(本地,快速)。
一个有用的观察是:翻译和修复用到的输入完全不同——一个需要文字,另一个需要像素——所以它们没有理由串行执行:
analyze
├─ translate (API) ─┐ independent: text vs pixels
└─ inpaint (GPU) ─┘ run concurrently
render
页面延迟变成了 analyze + max(translate, inpaint) + render,而不是三者之和。中间两个阶段都是网络绑定的,所以这几乎不花额外成本。
可以进一步优化。翻译只需要 OCR 后的文字,这在你把任何东西光栅化之前就存在了——所以在页面位图加载到内存之前就开始翻译,意味着内存密集型的像素阶段永远不会因为一个网络调用而阻塞。对于一份长文档,这决定了内存使用是舒适的还是会 OOM。
在这里顺便提一个 LLM 工程上的经验:把整段整段的内容批量放入一个请求,用带索引的片段输入,严格 JSON 输出。
src = [{"i": i, "t": t} for i, t in enumerate(texts)]
payload 中要有索引,而不只是数组顺序。如果模型漏掉了一个片段,你希望那一个段落回退到原文——而不是后续每个段落都向前错位一个,跑到错误的框里。这种失败是静默的,从渲染后的 PDF 来调试是非常痛苦的。
如果你打算在这个方向上构建,难度不在它看起来在的地方。以下是一些看起来比实际更难的东西,没有哪个是我这个实现特有的:
OCR 错误在下游是不可恢复的。如果 OCR 因为分隔线太淡而合并了两行表格,无论后续渲染做得多好都无济于事。质量的上限在上游,而大多数感觉像"布局 bug"的问题其实是 OCR bug。
文字系统不是互换的。日文竖排需要一个不同的布局模型,不是简单旋转一下。阿拉伯文需要连笔衔接和从右到左方向,才能在连续三次格式转换后存活下来。每添加一种文字系统都是实实在在的工作,不是换个字体就能解决的。
每一种失败都是视觉上的。你没法对"这看起来对不对"做单元测试。找到一种能快速目测页面级差异的方法,其重要性超出它应有的程度。
如果非要总结一个通用教训:对于扫描文档,翻译质量很少是瓶颈。把墨迹从页面上干净地清除掉,再把新墨迹放回去、放得恰到好处,才是感知质量的来源。一段放在正确位置的普通翻译,比一段放在乱排文字墙里的优秀翻译看起来更好。
这里描述的流水线运行在 tryreglyph.com,如果想拿一份扫描件试试的话。欢迎在评论区深入讨论——尤其是蒙版这一步,我很想知道其他人是怎么处理的。