文章目录[隐藏]
有些机器不会出现在最终成果的署名里,却会在很长一段时间里承担最具体、最笨重、也最容易被忽略的工作。Q7 节点就是这样一台机器。
它没有漂亮的演示界面,也没有一句话就能说清楚的“智能化”故事。它做的事情很朴素:接收扫描图像,运行 OCR,保存坐标和识别结果,重建带隐藏文本层的 PDF,再接受一次又一次关于“这一栏为什么跑到那一栏去了”的检查。
这篇文章既是纪念,也是一次技术复盘。文章刻意不记录真实网络地址、账号、口令、内网路径和其他可用于定位基础设施的信息;文中的“Q7 节点”“处理节点”“存储阵列”都是抽象称谓。
一、问题并不是“把 PDF 变成文字”
最开始看起来,任务似乎很简单:给一批扫描书做 OCR,然后把文字放回 PDF。真正开始以后才发现,难点至少有四层:
- 识别层:文字要识别出来,中文、外文、音标、数字、标点和谱例附近的内容不能全部混成噪声。
- 坐标层:识别结果必须保留在页面上的位置,否则文字虽然能搜索,却无法与原图对应。
- 阅读顺序层:双栏、三栏、目录、索引、表格和附录不能用同一个排序规则。
- 交付层:文件要有正确页数、正常底图、可检索文本,还要集中存放并能够复核校验。
其中最容易被低估的是第三层。PDF 阅读器并不理解“这是一本双栏词典”。如果文本对象写入方式不当,阅读器会按照几何位置重新推断顺序,把两个栏中同一高度的文字拼在一起。于是,一个肉眼看起来仍然是原页面的 PDF,复制出来的文字却已经失去了阅读顺序。
二、第一本:先把大规模 OCR 的基本链路跑通
第一阶段处理的是《中国大百科全书·音乐、舞蹈》这一册,书号为 12942843。最终版本为 1084 页,OCR 产生了 115,685 个文本框。过程中做了坐标校正,并针对多种页面布局进行回归检查。
这一步的意义不只是得到一本可搜索 PDF,更重要的是把完整链路固定下来:
- 以页面图像作为不可变的视觉底图;
- 对页面执行 OCR,保存文本、置信度和多边形坐标;
- 把 OCR 坐标映射到重建 PDF 的页面坐标;
- 以不可见文本方式写入隐藏文本层;
- 检查页数、底图数量、文本层和代表页阅读顺序。
在这本书上得到的教训是:布局分类必须有回归样本。不能只看一页“看起来正常”,而要固定检查正文、目录、索引、图文页和容易发生错序的页面。
三、四本音乐工具书的集中处理
之后把同一类工作集中到四本音乐工具书上,并将最终文件统一放入一个独立交付目录,避免成果散落在 OCR 临时目录、PDF 目录和图片目录中。
| 书号 | 书名 | 页数 | 最终处理重点 |
|---|---|---|---|
| 12942843 | 中国大百科全书·音乐、舞蹈 | 1084 | 坐标校正、布局回归、整本 searchable PDF |
| 10418763 | 中国音乐词典·续编 | 319 | 正文双栏、后段索引三栏 |
| 14277438 | 中国音乐词典·增订版 | 1122 | 前置页、正文双栏、后段索引三栏 |
| 10418631 | 音乐百科词典 | 933 | 目录和名单页例外、正文双栏、索引三栏 |
四、两本《中国音乐词典》:固定栏位比万能排序更可靠
10418763 和 14277438 的 OCR JSON 使用了较新的结果字段,包含文本和识别多边形。它们的页面结构有明显规律:
- 普通正文页是双栏;
- 书末索引页是三栏;
- 目录、撰稿人名单等前置页面可能有自己的双栏结构;
- 部分高密度表格页不适合简单地当作普通正文处理。
最初的尝试虽然按照栏位排序了文本,却把所有文本对象写进了同一个文本内容流。结果是:某些页面在 PDF 内部又被阅读器横向重组。这个问题并不是 OCR 错了,而是 PDF 写入层的语义不够明确。
修正方案是让每个物理栏使用独立的文本写入器和独立内容流:
for column in columns:
writer = TextWriter(page)
for text_box in sort_top_to_bottom(column):
writer.append(text_box.position, text_box.text)
writer.write_text(page, invisible=True)这样,双栏页面的逻辑顺序明确为“左栏全部内容,再右栏全部内容”;三栏页面则明确为“第一栏、第二栏、第三栏”。一旦页面已经确认是双栏或三栏,后续阶段不再允许把栏重新交错。
五、10418631:用 100 DPI 和 150 DPI 做了实际对照
《音乐百科词典》的旧 OCR 结果来自另一套批处理格式。它的页面文件顺序由封面、版权页、前言、目录、正文和附录组成,不能简单按照文件名的数字顺序理解。后来使用页面 manifest 固定了 933 页的真实顺序和图片映射。
这本书还专门比较了整页 OCR 在 100 DPI 和 150 DPI 下的结果。
| 测试页 | 100 DPI | 150 DPI | 结论 |
|---|---|---|---|
| 正文页 | 108 行,平均置信度约 0.987 | 108 行,平均置信度约 0.987 | 差异不大,整页识别没有真正跨栏的大框 |
| 索引页 | 147 行,平均置信度约 0.901 | 268 行,平均置信度约 0.982 | 150 DPI 能明显改善词条与页码的拆分 |
这次对照改变了原先的判断:不应该因为页面是双栏就默认裁成左右两张图重新识别。对于这本书,当前 OCR 服务在 150 DPI 整页识别时已经能够识别出正文的独立文本框;真正需要提升的是索引页的分辨率,而不是盲目裁栏。
最终采用处理节点上的 OCR 服务,以 150 DPI 对 933 页整页重新识别,共得到 115,843 个文本框,失败数为零。然后保留原始整页图片,只替换隐藏文本层,并采用独立栏内容流写入。
六、前置页面不能一刀切
处理过程中还确认了一个很容易被忽略的边界:目录页也可能是双栏,撰稿人名单也可能是双栏。不能写成“前十页全部单栏、正文全部双栏”这种过度简化的规则。
最终采用的是书级规则加明确例外:
- 封面、版权页和普通前言按自然顺序;
- 明确检测为双栏的目录页和名单页按双栏;
- 正文按书本版式使用双栏;
- 索引和术语附录按三栏;
- 高密度表格页保留逐行顺序,不强行套用正文栏规则。
七、最后的验收不是“文件生成了”
四本书最终集中交付后,逐本检查了以下项目:
- PDF 页数与预期一致;
- 每个代表页都有且只有对应底图;
- 有 OCR 内容的页面能够搜索和复制;
- 正文双栏和索引三栏使用独立栏内容流;
- 关键词搜索能够命中;
- 交付目录附带 manifest、README 和 SHA-256 校验清单。
其中 10418763 的第一页是封面,原始 OCR 没有产生文本框,因此它是一个有意保留的空文本封面,不是重建失败。其他需要文字检索的页面均有文本层。
附:几段脱敏后的实现骨架
下面的代码不是某台机器上的原样脚本,而是从实际处理流程中抽出的最小化示例。它们故意不包含服务器地址、账号、口令、真实路径和具体书库名称,读者可以把它们改造成自己的处理工具。
1. 按几何位置把 OCR 框分到物理栏
关键点不是把所有框按 (y, x) 全局排序,而是先确定栏,再在每个栏内部从上到下排序。栏数应当来自页面类型判断或书级规则,不能对所有页面盲猜。
def split_into_columns(boxes, page_width, column_count):
"""boxes: (text, polygon);返回按物理栏排列的列表。"""
columns = [[] for _ in range(column_count)]
for index, (text, polygon) in enumerate(boxes):
xs = [point[0] for point in polygon]
ys = [point[1] for point in polygon]
x0, x1 = min(xs), max(xs)
y0, y1 = min(ys), max(ys)
center_x = (x0 + x1) / 2
column = int(center_x / page_width * column_count)
column = max(0, min(column_count - 1, column))
columns[column].append((index, text, x0, y0, x1, y1))
def reading_key(item):
index, text, x0, y0, x1, y1 = item
return (y0, x0, index)
return [sorted(column, key=reading_key) for column in columns]2. 每栏使用独立的 PDF 文本内容流
这是本次最重要的修正之一。即使已经完成分栏,如果所有文字都写入同一个文本流,部分 PDF 阅读器仍可能按照几何位置再次重排。每栏单独写入,可以把“第一栏全部内容、第二栏全部内容……”表达得更明确。
def add_invisible_text_by_column(page, columns, fitz):
font = fitz.Font("china-s")
inserted = 0
for column in columns:
writer = fitz.TextWriter(page.rect)
column_inserted = 0
for _, raw_text, x0, y0, x1, y1 in column:
text = raw_text.strip()
rect = fitz.Rect(x0, y0, x1, y1) & page.rect
if not text or rect.is_empty:
continue
fontsize = max(1.0, min(rect.height * 0.82, 100.0))
measured = font.text_length(text, fontsize=fontsize)
if measured > rect.width:
fontsize = max(1.0, fontsize * rect.width / measured)
baseline = fitz.Point(
rect.x0,
min(rect.y1 - rect.height * 0.08, rect.y0 + fontsize),
)
writer.append(baseline, text, font=font, fontsize=fontsize)
inserted += 1
column_inserted += 1
if column_inserted:
# render_mode=3:不可见文字;实际参数按所用 PDF 库调整。
writer.write_text(page, render_mode=3, overlay=True)
return inserted3. 对不同 DPI 做小样对照,而不是凭感觉选参数
def compare_dpi(image, ocr, dpi_values=(100, 150)):
results = []
for dpi in dpi_values:
scaled = resize_for_dpi(image, dpi)
result = ocr(scaled)
scores = result.get("scores", [])
results.append({
"dpi": dpi,
"line_count": len(result.get("texts", [])),
"mean_score": sum(scores) / max(1, len(scores)),
"boxes": result.get("boxes", []),
})
return results
# 正文页和索引页应分别抽样;索引页往往更依赖分辨率。
for sample in (body_sample, index_sample):
print(compare_dpi(sample, ocr_service))4. 页面级验收:不要只检查文件是否生成
def validate_searchable_pdf(pdf_path, expected_pages, fitz):
document = fitz.open(pdf_path)
assert document.page_count == expected_pages
empty_text_pages = []
bad_image_pages = []
for page_number, page in enumerate(document, start=1):
if len(page.get_images(full=True)) != 1:
bad_image_pages.append(page_number)
if not page.get_text(sort=False).strip():
empty_text_pages.append(page_number)
return {
"pages": document.page_count,
"empty_text_pages": empty_text_pages,
"bad_image_pages": bad_image_pages,
"text_chars": sum(len(page.get_text(sort=False)) for page in document),
}实际项目中还应加入代表页的顺序回归:正文前、中、后各抽一页,目录抽一页,索引抽数页,比较预期栏序和 PDF 实际复制出的文本序列。对于封面、插图页和空白页,空文本应作为已知页面类型记录,而不是简单判定为失败。
5. 批量书库先审计,再决定是否重建
def classify_book(book):
report = audit_assets(book)
if not report.pdf_exists or not report.images_complete:
return "F" # 资产不完整
if report.single_column_ratio > 0.95 and report.order_is_normal:
return "A" # 无需重建
if report.cross_column_ratio < 0.01 and report.stable_two_column:
return "B" # 稳定双栏
if report.has_body_two_and_index_three:
return "C" # 正文双栏、索引三栏
if report.has_tables_or_complex_pages:
return "D" # 需要书级规则
if report.ocr_boxes_are_merged or report.low_confidence_ratio > 0.2:
return "E" # 需要局部或整本重 OCR
return "D"这个分类器的目的不是追求一个神奇的总分,而是把风险暴露出来:哪些书可以不动,哪些书只需要重建文本流,哪些书需要特殊页规则,哪些书必须先重新 OCR。只有通过抽样验收,才应该进入下一批自动处理。
八、不能把四本书的规则直接套给三千本书
四本书处理完成后,真正值得继续思考的是更大的书库。当前还有两批 searchable PDF 资产,规模分别约为 260 本和 3055 本。它们不能直接套用这四本书的页段规则。
更稳妥的批量治理方案应该是先审计、再分层:
| 类别 | 特征 | 建议 |
|---|---|---|
| A | 单栏且文本顺序正常 | 不重建 |
| B | 全书稳定双栏 | 固定双栏,独立内容流 |
| C | 正文双栏、索引三栏 | 按页段设置规则 |
| D | 目录、表格、图文混排较多 | 书级规则加例外页 |
| E | 旧 OCR 框已跨栏粘连或质量不足 | 仅对问题页重新 OCR |
| F | 图片、JSON 或页数不完整 | 先修复资产,不进入批量重建 |
审计器应当只读完成以下工作:比对页数、抽样检查栏位、统计跨栏框、检测文本层是否为空、抽取正文和索引代表页,并输出每本书的分类报告。通过小样验收后,再按书型分批处理,而不是启动一个“万能重建器”覆盖全部文件。
九、给后来者的几个具体建议
- 先确认页面顺序,再处理文本顺序。文件名排序不一定等于书页排序,尤其是包含封面、前言、目录和附录的书。
- OCR 坐标和 PDF 坐标必须统一。分辨率、缩放比例和页面原点任何一个不一致,都会造成“搜索得到但定位不准”。
- 栏位是硬边界。判断为双栏或三栏以后,排序、写入和验收都不允许重新跨栏。
- 不要把 PDF 内容流当成无关细节。视觉层正确,不代表复制和朗读顺序正确。
- 索引页优先提高分辨率。词条、点线和页码通常比正文更需要清晰的字符分离。
- 失败页要单独记录。空封面、插图页和表格页不应该被粗暴地标记成“全书失败”。
- 交付文件要集中并有校验清单。技术成果如果找不到,和没有成果差不多。
结语:机器也有它的工作史
Q7 节点留下的不是一条漂亮的演示曲线,而是一批能够真正搜索、复制、引用和继续加工的书。它经历过等待、误判、重新测试,也经历过“看起来已经完成,但复制出来的顺序仍然不对”这种只有实际使用才会暴露的问题。
这类工作很少有一个瞬间可以宣布“智能完成”。更多时候,它是一点点把不可靠的地方找出来,再把规则写得比上一次更诚实:哪些页面可以自动处理,哪些页面必须抽样,哪些页面应该重 OCR,哪些页面根本不该被强行归类。
所以,这篇文章纪念的并不只是某一台机器,也不只是四本书。它纪念的是一段把扫描图像变成公共知识入口的过程:让旧书重新获得搜索能力,让版面顺序不再被技术细节悄悄破坏,也让下一批书的处理少走一点弯路。
致 Q7 节点,也致所有在后台安静工作的处理设备。