纪念 Q7 节点:从万页 OCR 到可搜索 PDF 的一次文档工程复盘

有些机器不会出现在最终成果的署名里,却会在很长一段时间里承担最具体、最笨重、也最容易被忽略的工作。Q7 节点就是这样一台机器。

它没有漂亮的演示界面,也没有一句话就能说清楚的“智能化”故事。它做的事情很朴素:接收扫描图像,运行 OCR,保存坐标和识别结果,重建带隐藏文本层的 PDF,再接受一次又一次关于“这一栏为什么跑到那一栏去了”的检查。

这篇文章既是纪念,也是一次技术复盘。文章刻意不记录真实网络地址、账号、口令、内网路径和其他可用于定位基础设施的信息;文中的“Q7 节点”“处理节点”“存储阵列”都是抽象称谓。

一、问题并不是“把 PDF 变成文字”

最开始看起来,任务似乎很简单:给一批扫描书做 OCR,然后把文字放回 PDF。真正开始以后才发现,难点至少有四层:

  • 识别层:文字要识别出来,中文、外文、音标、数字、标点和谱例附近的内容不能全部混成噪声。
  • 坐标层:识别结果必须保留在页面上的位置,否则文字虽然能搜索,却无法与原图对应。
  • 阅读顺序层:双栏、三栏、目录、索引、表格和附录不能用同一个排序规则。
  • 交付层:文件要有正确页数、正常底图、可检索文本,还要集中存放并能够复核校验。

其中最容易被低估的是第三层。PDF 阅读器并不理解“这是一本双栏词典”。如果文本对象写入方式不当,阅读器会按照几何位置重新推断顺序,把两个栏中同一高度的文字拼在一起。于是,一个肉眼看起来仍然是原页面的 PDF,复制出来的文字却已经失去了阅读顺序。

二、第一本:先把大规模 OCR 的基本链路跑通

第一阶段处理的是《中国大百科全书·音乐、舞蹈》这一册,书号为 12942843。最终版本为 1084 页,OCR 产生了 115,685 个文本框。过程中做了坐标校正,并针对多种页面布局进行回归检查。

这一步的意义不只是得到一本可搜索 PDF,更重要的是把完整链路固定下来:

  1. 以页面图像作为不可变的视觉底图;
  2. 对页面执行 OCR,保存文本、置信度和多边形坐标;
  3. 把 OCR 坐标映射到重建 PDF 的页面坐标;
  4. 以不可见文本方式写入隐藏文本层;
  5. 检查页数、底图数量、文本层和代表页阅读顺序。

在这本书上得到的教训是:布局分类必须有回归样本。不能只看一页“看起来正常”,而要固定检查正文、目录、索引、图文页和容易发生错序的页面。

三、四本音乐工具书的集中处理

之后把同一类工作集中到四本音乐工具书上,并将最终文件统一放入一个独立交付目录,避免成果散落在 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 DPI150 DPI结论
正文页108 行,平均置信度约 0.987108 行,平均置信度约 0.987差异不大,整页识别没有真正跨栏的大框
索引页147 行,平均置信度约 0.901268 行,平均置信度约 0.982150 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 inserted

3. 对不同 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 或页数不完整先修复资产,不进入批量重建

审计器应当只读完成以下工作:比对页数、抽样检查栏位、统计跨栏框、检测文本层是否为空、抽取正文和索引代表页,并输出每本书的分类报告。通过小样验收后,再按书型分批处理,而不是启动一个“万能重建器”覆盖全部文件。

九、给后来者的几个具体建议

  1. 先确认页面顺序,再处理文本顺序。文件名排序不一定等于书页排序,尤其是包含封面、前言、目录和附录的书。
  2. OCR 坐标和 PDF 坐标必须统一。分辨率、缩放比例和页面原点任何一个不一致,都会造成“搜索得到但定位不准”。
  3. 栏位是硬边界。判断为双栏或三栏以后,排序、写入和验收都不允许重新跨栏。
  4. 不要把 PDF 内容流当成无关细节。视觉层正确,不代表复制和朗读顺序正确。
  5. 索引页优先提高分辨率。词条、点线和页码通常比正文更需要清晰的字符分离。
  6. 失败页要单独记录。空封面、插图页和表格页不应该被粗暴地标记成“全书失败”。
  7. 交付文件要集中并有校验清单。技术成果如果找不到,和没有成果差不多。

结语:机器也有它的工作史

Q7 节点留下的不是一条漂亮的演示曲线,而是一批能够真正搜索、复制、引用和继续加工的书。它经历过等待、误判、重新测试,也经历过“看起来已经完成,但复制出来的顺序仍然不对”这种只有实际使用才会暴露的问题。

这类工作很少有一个瞬间可以宣布“智能完成”。更多时候,它是一点点把不可靠的地方找出来,再把规则写得比上一次更诚实:哪些页面可以自动处理,哪些页面必须抽样,哪些页面应该重 OCR,哪些页面根本不该被强行归类。

所以,这篇文章纪念的并不只是某一台机器,也不只是四本书。它纪念的是一段把扫描图像变成公共知识入口的过程:让旧书重新获得搜索能力,让版面顺序不再被技术细节悄悄破坏,也让下一批书的处理少走一点弯路。

致 Q7 节点,也致所有在后台安静工作的处理设备。

暂无评论

发送评论 编辑评论

|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇