1. 复杂版面 OCR 到底难在哪:先把问题拆开
做文档处理这行的,几乎都遇到过同一类需求:手上一批 PDF,有的是期刊论文那种规整双栏,有的是首页单栏摘要、正文双栏的单双栏混合,还有的通篇图表、公式、图注、脚注满天飞。你想把里面的文字提出来做成 Markdown 或者结构化 JSON,结果一跑 OCR,出来的文字跟打乱的扑克牌一样——左栏第三行接上了右栏第一行,表格里的数字串进了正文,公式的上下标被拆成孤零零的几行。这不是模型不行,是版面没被理解。
这篇内容聊的就是这件事:怎么对付双栏、单双栏混合、图表文字混合这类复杂布局的图片和整本 PDF 的 OCR 识别。我会从版面分析的底层逻辑讲起,把双栏切分、阅读顺序还原、表格与公式的隔离、跨页处理这些环节一个个拆开,给出能直接照抄的参数和代码。适合谁看?如果你只是偶尔识别几张发票、截图,用现成工具点两下就够了,这篇对你可能有点重;但如果你要批量处理几百上千页的学术 PDF、技术手册、报告文档,或者你要在自己系统里嵌一套稳定的解析流水线,那这里面的坑我基本都替你踩过一遍了。
先明确一个认知:OCR 准确率和版面还原准确率是两个独立指标。很多团队把精力全砸在换 OCR 模型上,从通用模型换到更重的模型,字符准确率从 97% 提到 98.5%,但用户还是骂,因为阅读顺序错了。真正决定可用性的是版面理解和阅读顺序还原,字符错几个用户可以忍,整段串栏是致命的。
1.1 三类版面的难度差异在哪
纯双栏其实是最简单的一类。典型结构是:通栏标题、通栏作者信息、双栏正文、通栏页脚。栏与栏之间有一条稳定的竖向空白带,宽度通常在 5 到 15 毫米之间,把页面从中间劈开,各自独立识别就行。这类页面用投影法切栏的准确率能到 95% 以上,几乎不需要深度学习模型。
单双栏混合才是第一道坎。常见形态有三种:一是首页单栏、后续页双栏,需要按页判断;二是双栏正文中间插入通栏大图或者通栏表格,这个通栏元素像一道横线把页面切成上下两个区间,区间内还是双栏;三是左侧窄边注加右侧单栏正文,视觉上像双栏但阅读顺序完全不同。这三种情况的处理逻辑彼此冲突,不能用一套固定规则硬套。
图表文字混合的难度又上一级。表格有合并单元格、无线表、跨页续表;公式有行内和行间,行间公式常带编号;图有图注、子图标注、图例文字;还有脚注、侧边注、参考文献编号、页边批注。这些元素如果被当成普通正文送进 OCR,出来的就是垃圾。更麻烦的是,很多元素在视觉上是"文字",但语义上必须整体保留或者整体丢弃,比如表格里一个单元格的换行,你既不能合并成一行,也不能当成两个段落。
注意:不要指望一个通用 OCR 接口解决全部问题。整页直接丢进去识别,本质上是让模型在没有任何版面先验的情况下猜顺序,双栏页面的错误率通常在 10% 到 30% 之间,页数一多,人工返工的代价远超自建流水线。
1.2 传统流水线在双栏上翻车的真实原因
拿最经典的开源 OCR 引擎举例,它内置的页面分割模式有好几种,常用的全自动模式会先做连通域分析,再用投影法切分行和列。问题出在这个"先横切再纵切"的顺序上:双栏页面里,左右两栏同一水平位置的文字,在水平投影上完全重叠,算法很容易把它们判定为"同一行",于是左栏一句、右栏一句被拼在一起。你会发现输出里出现"本文提出了一种新的方法实验结果如表 1 所示"这种句子,前半句在左栏,后半句在右栏。
有人会想,那把页面分割模式改成"单列变长行"或者"单个文本块"不就行了?改成单列模式后,双栏确实不会被串,但跨栏的通栏标题、通栏表格会被强行切碎,而且每一栏内部的行合并逻辑会退化。这就陷入了一个两难:页面级的分割模式是全局的,而真实文档的版式是局部变化的。
这也是为什么现在主流方案都转向了"版面检测 + 块级识别"的思路:先用一个检测模型把页面切成语义块,每个块标注类型(正文、标题、表格、图、公式、页眉页脚),然后只在文本块内部做行识别,最后按块的坐标还原阅读顺序。识别和排序解耦,各自可以独立优化,出错也好定位。
1.3 先判断你的 PDF 属于哪一类,别急着上 OCR
这一步很多人跳过,直接导致后面白干。PDF 分三种:有完整文本层的电子版、纯扫描件、混合件(部分页有文本层,部分页是扫描图)。
有文本层的电子版,第一反应应该是直接抽文本,而不是 OCR。但这里有个大坑:有文本层不等于文本层顺序是对的。不少排版软件导出的 PDF,文本流顺序是排版对象的创建顺序,不是阅读顺序。你抽出来的文字可能是一堆碎片,段落被拆得七零八落。所以正确做法是先抽一遍,检查连读性和段落完整性,判断文本层是否可用。
判断逻辑很简单,用 PyMuPDF 扫一遍:
import fitz def classify_page(page, area_ratio=0.0005): text = page.get_text("text").strip() rect = page.rect area = rect.width * rect.height # 文本密度:字符数 / 页面面积 density = len(text) / area if area else 0 if len(text) < 30: return "scan" # 几乎无文本层,扫描件 if density < area_ratio: return "sparse" # 文本层稀疏,可能是图为主 return "text" doc = fitz.open("input.pdf") stats = {} for i, page in enumerate(doc): kind = classify_page(page) stats.setdefault(kind, []).append(i) print({k: len(v) for k, v in stats.items()})跑完看统计,如果text类占比超过 85%,优先走文本层抽取加版面修正的路线,OCR 只当兜底;如果scan占比高,那就老老实实走图像 OCR。混合件最麻烦,得按页分流,同一本书里两种处理逻辑并存,输出格式还要对齐。
2. 整体方案怎么设计:从"识别文字"到"还原顺序"
方案选型这件事,我见过太多团队一上来就纠结用哪个模型,其实应该先问自己三个问题:你的文档版式是否固定?你对结构化程度的要求有多高?出错后你能接受多大的人工返工量?
如果版式高度固定(比如某一类期刊),写规则就够了,投影切栏加坐标排序,几百行代码搞定,比任何模型都快且可控。如果版式五花八门,那必须上版面检测模型。如果要求输出带层级的 Markdown(标题分级、列表、表格),那还得加一层结构推断。这三条路线的工程量差一个数量级,选错了要么过度设计,要么反复返工。
2.1 三条技术路线的取舍
端到端方案,指的是把整页图丢给一个已经集成了版面分析和结构还原的解析器,直接吐出 Markdown。这类工具这两年成熟很快,对学术论文的默认支持相当不错,双栏、表格、公式基本能处理,装完就能跑。优点是上手半天出结果,缺点是黑盒——某一页识别错了,你很难定位是检测错了、排序错了还是识别错了,改不动。而且这类工具往往有自己的依赖栈和模型权重,部署体积不小,在受限环境里跑起来费劲。
自建流水线方案,是把整个流程拆成七个环节:页面分类、图像预处理、版面块检测、块级识别、阅读顺序还原、后处理清洗、格式化输出。每个环节都能单独替换和调参,出错能定位到具体环节。缺点是工作量大,尤其是在阅读顺序和后处理上,需要大量真实样本反复调。
混合方案是我实际项目里用得最多的:主体用现成的版面检测模型拿块坐标,顺序还原和后处理完全自己写,表格和公式这类难啃的块单独走专用模型,实在搞不定的直接保留裁图。这样既省了训练检测模型的时间,又保住了关键环节的可控性。
| 方案 | 上手成本 | 双栏准确率 | 可控性 | 适合场景 |
|---|---|---|---|---|
| 整页直接 OCR | 极低 | 60% 到 75% | 无 | 单栏、纯文字、应急 |
| 端到端解析器 | 低 | 85% 到 92% | 低 | 学术论文批量转 Markdown |
| 自建流水线 | 高 | 93% 到 98% | 高 | 生产环境、需长期维护 |
| 混合方案 | 中 | 90% 到 96% | 中高 | 大多数工程落地场景 |
2.2 我推荐的流水线骨架
具体的环节设计是这样:
第一步,页面分类。按 1.3 的逻辑把每页打上text、scan、sparse标签,顺便记录页面尺寸和旋转角度。旋转角度这个别忽略,扫描件里 90 度、180 度歪的页占比不低,方向检测不做,后面全废。
第二步,图像渲染。只对scan和sparse页渲染,text页走文本层抽取。渲染参数统一,DPI 按内容复杂度分档,后面细说。
第三步,版面检测。输出每个块的类别和坐标。类别至少要区分正文、标题、表格、图、公式、页眉、页脚、页码。
第四步,块级识别。正文和标题走 OCR,表格走表格识别,公式走公式识别或直接裁图,图块只保留坐标和裁图。
第五步,阅读顺序还原。这是核心,按通栏元素分割页面,区块内分栏排序。
第六步,后处理。断行合并、连字符处理、页眉页脚剔除、中英文间距规范、段落边界判定。
第七步,组装输出。按顺序拼接成 Markdown,同时保留一份带 bbox 的 JSON,方便回溯和二次加工。
这套流程的好处是每一环都可以单独做消融实验。比如你怀疑准确率卡在排序上,就把中间结果 dump 出来,只看块坐标和排序结果,很快能定位问题。
2.3 工具选型:别迷信单一方案
版面检测这块,可选的开源方案不少。基于检测框架的版面模型(比如 PP-Structure 系列里的 layout 模型)类别划分细,输出直接是"文本/标题/表格/图/页眉页脚/参考文献/公式"这几类,中文文档上表现稳,缺点是模型体积偏大,首次加载慢。另一类是较新的文档解析工具,整合了版面检测和阅读顺序,输出直接是带层级的文档树,适合快速验证。还有一些偏轻量的方案,只做行检测不做语义分类,用来处理单栏文档够用。
表格识别要看你的表格类型。有完整框线的表格,用形态学找线加单元格切分,准确率很高而且快;无线表格和合并单元格多的,得上专用表格结构识别模型;跨页表格目前没有特别省心的通用方案,基本都要自己写合并逻辑。
公式识别是最容易被低估的一环。行间公式如果混进正文 OCR,会输出一堆乱码并污染上下文。我的建议是:先定位再决定,宁可保留图片也不要输出错公式。如果你确实需要 LaTeX,用专门的公式识别模型,但要有心理准备,复杂多行公式的准确率仍然不高。
| 环节 | 轻量方案 | 稳妥方案 | 备注 |
|---|---|---|---|
| 版面检测 | 投影法 + 规则 | 版面检测模型 | 版式固定用前者 |
| 正文 OCR | RapidOCR | PP-OCR 系列 | 中文场景优先 |
| 表格结构 | OpenCV 找线 | 表格结构识别模型 | 无线表必须用模型 |
| 公式 | 整块裁图 | 公式识别模型 | 裁图更保险 |
| 阅读顺序 | 规则排序 | 规则 + 模型打分 | 规则可控性更高 |
3. 核心环节实操:预处理、版面分析、顺序还原
3.1 DPI 怎么定:算一笔账再动手
这是最容易被拍脑袋决定的参数。DPI 太低,小字号糊成一团;太高,显存爆掉、速度慢十倍,而且未必更准。先算清楚像素和字号的关系:1 pt 等于 1/72 英寸,所以字号对应的像素高度等于字号除以 72 再乘以 DPI。
按这个公式算:10 pt 的正文字,在 200 DPI 下单字高度约 27.8 像素,在 300 DPI 下约 41.7 像素,在 400 DPI 下约 55.6 像素。多数 OCR 模型的训练样本里,文字高度集中在 30 到 50 像素这个区间,所以 300 DPI 对 9 到 12 pt 的正文是最舒服的档位。但文档里常有 6 到 7 pt 的脚注和表格注释,300 DPI 下只有 25 到 29 像素,识别率会明显掉。这时候有两个选择:整体提到 400 到 500 DPI,或者整体保持 300 DPI、单独把脚注区域放大后再识别一遍。后者更省算力,我一般用后者。
还有一个隐形成本:像素量是 DPI 的平方关系。300 DPI 提到 400 DPI,单页像素量增加约 78%;提到 600 DPI,是原来的 4 倍。整本书几百页跑下来,时间和显存占用差得非常明显。所以我的默认策略是按内容分档:
- 纯文字正文页:300 DPI
- 含表格、公式、脚注的页:400 DPI
- 版面检测用低分辨率:150 DPI 就够,检测框对小字号不敏感,用低分辨率能快好几倍
渲染代码用 PyMuPDF 最省事,不依赖外部二进制:
import fitz def render_page(doc, pno, out_dir, dpi=300, gray=True): page = doc[pno] cs = fitz.csGRAY if gray else fitz.csRGB pix = page.get_pixmap(dpi=dpi, colorspace=cs, alpha=False) path = f"{out_dir}/p{pno:05d}_{dpi}.png" pix.save(path) return path, pix.width, pix.height doc = fitz.open("input.pdf") for i in range(len(doc)): dpi = 400 if i in {12, 13, 47} else 300 render_page(doc, i, "pages", dpi=dpi)注意:不要用二值化图去做 OCR。很多人习惯先转灰度再自适应二值化,觉得"更清晰",实测下来反而不如灰度原图。二值化会丢掉抗锯齿边缘的灰度过渡,小字号文字尤其吃亏。只有在扫描噪点特别严重、背景发灰的情况下才做二值化,而且要配合去噪。
3.2 双栏判定的三种方法,我为什么选了投影加聚类
判断一页是不是双栏,方法有好几种,各有适用场景。
第一种,纯投影法。把页面转成二值图,按列统计前景像素数量,得到一条垂直投影曲线。双栏页面的栏间空白在投影曲线上表现为一段接近零的低谷。找低谷的中点就能切栏。这方法快、直观,但只适合纯双栏,遇到通栏元素就失效——通栏的标题会让整段投影都变高,低谷消失。
第二种,文本块中心点聚类。先做版面检测拿到所有文本块,取每个块的中心 x 坐标,做一维聚类。如果聚成两类且两类之间有明显间隔,就判定双栏。这方法对混排更稳,因为通栏块的中心点落在中间,会把聚类结果搅乱,所以要先剔除宽度占比大的块。
第三种,看块的 x 区间分布。统计所有文本块的左边界和右边界,如果存在一批块集中在页面左半部分、另一批集中在右半部分,且两组块的水平区间不重叠,那就是双栏。
我实际用的是第二种加第三种组合:先按宽度把通栏块挑出来,剩下的块做中心点聚类,再看两簇的区间是否真的分离,两个条件都满足才判双栏。这样误判率低很多,尤其是那些"左栏宽右栏窄"或者"左边正文右边边注"的页面,不会简单粗暴地被判定为等宽双栏。
import numpy as np def cluster_1d(values, tol=40): """简单的一维聚类:排序后按间隔切分""" vals = sorted(values) groups, cur = [], [vals[0]] for v in vals[1:]: if v - cur[-1] > tol: groups.append(cur) cur = [v] else: cur.append(v) groups.append(cur) return [float(np.mean(g)) for g in groups] def detect_two_column(blocks, page_w, full_ratio=0.65): body = [b for b in blocks if b["type"] in ("text", "title") and (b["x1"] - b["x0"]) < page_w * full_ratio] if len(body) < 6: return False, [] centers = [(b["x0"] + b["x1"]) / 2 for b in body] centers_x = cluster_1d(centers, tol=page_w * 0.08) if len(centers_x) != 2: return False, centers_x # 校验两簇的水平区间是否真的分离 left = [b for b in body if (b["x0"] + b["x1"]) / 2 < np.mean(centers_x)] right = [b for b in body if (b["x0"] + b["x1"]) / 2 >= np.mean(centers_x)] left_max = max(b["x1"] for b in left) right_min = min(b["x0"] for b in right) gap = right_min - left_max return gap > page_w * 0.02, centers_x3.3 阅读顺序还原:先切带,再切栏,最后排序
顺序还原是整个流程里最容易翻车的地方,也是最值得花时间打磨的地方。我用的策略是三层结构。
第一层,识别通栏元素。宽度超过页面宽度 65% 的块,包括通栏标题、通栏大图、通栏表格、通栏页脚,统称为通栏块。这些块是天然的"分隔符",按它们的上边界 y 坐标排序,把页面切成若干横向的"带"。一条通栏大图横在页面中间,就把页面分成上下两个独立区间,上区间先读完再读下区间。
第二层,带内分栏。每个带单独判断是单栏还是双栏。带宽很窄(比如只有两行文字)的带,直接按 y 排序,别硬套双栏规则;带宽足够的,用 3.2 的方法判定。
第三层,带内排序。双栏带内的排序规则是:左栏所有块按 y 从上到下,然后是右栏所有块按 y 从上到下。单栏带内直接按 y 排。每个块内部如果有行级信息,行按 y 排。
这里有个细节值得单独说:跨栏续接段落。有些文档里,一段话从左上栏底部继续到右上栏顶部,段末没有句号。如果机械地按栏排序,读起来就是"……实验结果如表"接上"1 所示,我们可以看出",语义断裂。判断续接的规则是:如果左栏最后一块的末尾字符不是句号、问号、感叹号、分号、冒号等终止标点,且右上栏首块的起始字符是小写字母、数字或者没有明显段首缩进,那就判定为续接,把两块合并成一段。这条规则命中率大概七成,剩下的靠人工或者语言模型兜底。
END_PUNCT = set("。!?;:.!?;:") TEXT_TYPES = {"text", "title", "reference"} def order_within_band(blocks, page_w): if not blocks: return [] is_two, _ = detect_two_column(blocks, page_w) if not is_two: return sorted(blocks, key=lambda b: (b["y0"], b["x0"])) mid = page_w / 2 left = sorted([b for b in blocks if (b["x0"] + b["x1"]) / 2 < mid], key=lambda b: b["y0"]) right = sorted([b for b in blocks if (b["x0"] + b["x1"]) / 2 >= mid], key=lambda b: b["y0"]) return left + right def order_page(blocks, page_w, page_h, full_ratio=0.65): body = [b for b in blocks if b["type"] not in ("header", "footer")] full = sorted([b for b in body if (b["x1"] - b["x0"]) > page_w * full_ratio or b["type"] in ("figure", "table")], key=lambda b: b["y0"]) ordered, prev_y = [], 0 body_nofull = [b for b in body if b not in full] for f in full: band = [b for b in body_nofull if prev_y <= b["y0"] < f["y0"]] ordered.extend(order_within_band(band, page_w)) ordered.append(f) prev_y = f["y1"] tail = [b for b in body_nofull if b["y0"] >= prev_y] ordered.extend(order_within_band(tail, page_w)) return ordered3.4 后处理:决定输出能不能直接用的最后一公里
后处理这块不起眼,但它决定了输出的东西是"能看"还是"能用"。我最常做的几件事:
断行合并。OCR 是按行输出的,一个段落被切成若干行。中文行末直接拼接,不需要加空格;英文行末如果是连字符,要去掉连字符直接拼,如果是普通空格,拼一个空格。判断新段落开始的信号:前一行末有终止标点、当前行首有缩进、行间距明显大于常规行距。这三个信号里满足两个就当新段落。
页眉页脚剔除。按位置过滤只能过滤掉一部分,因为有些版式的页眉在页面 10% 到 15% 的位置。更稳的做法是跨页统计:把同一本书所有页面在同一位置区域出现的高频短文本提取出来,出现次数超过总页数 60% 的,判定为页眉页脚模板,整批删除。页码单独用正则处理,^\s*[-—–]?\s*\d{1,4}\s*[-—–]?\s*$这种能覆盖大部分情况,但要注意区分页码和正文里单独成行的数字,可以加一个位置约束,只在页面上下 8% 区域内匹配。
中英文间距和标点统一。中文和英文、数字之间补一个半角空格,读起来舒服很多。全角数字和字母统一转半角,但中文标点保持全角。书名号、括号的中英文混用情况很乱,统一成中文文档习惯的样式。
段落去重。扫描件里偶尔会有同一段文字因为检测框重叠被识别两次,输出里出现重复段落。用相邻块文本相似度比对,相似度超过 0.9 的丢弃后者。
心得:后处理规则不要一次性写二十条,按真实样本的报错一个个加。我一开始写了三十多条规则,后来发现其中十几条从来没被触发,反而在特殊文档上误伤。规则越少越可控,每条规则都要能说清楚它解决哪个具体 case。
4. 图表文字混合页面:表格、公式、图注怎么各自安放
4.1 表格识别:先分类型,再选方法
表格识别错误率高,很大一部分原因是方法用错了对象。先把表格分成三类。
有完整框线的表格,优先用图像形态学处理:灰度化、自适应阈值、水平方向用一个扁长的核做腐蚀膨胀找横线,垂直方向用瘦高的核找竖线,横竖线求交得到单元格。这方法快而且准,只要框线没断。框线断裂的常见原因是扫描质量差或者 DPI 不够,把 DPI 提到 400 再试一次往往就解决了。
无线表格或者框线极淡的表格,只能靠文本块的坐标对齐关系推断。思路是把块按 y 聚类成行,行内按 x 排序,再根据列的 x 区间一致性划列。这种方法对对齐规整的表格效果不错,但遇到"某列内容很长自动折行"就会错位。折行的判断是:如果某个块的 y 区间跨越了行高但 x 区间和上一行的某一列重合,就判定为该列的续行。
带合并单元格的复杂表格,必须上结构识别模型。模型输出通常是 HTML 表格结构,带 rowspan 和 colspan 属性。这里有个坑要提醒:如果你最终输出目标是 Markdown,别把带合并单元格的表格转成 Markdown 表格,因为 Markdown 语法根本不支持合并,转了必然错乱。直接用 HTML 表格嵌进 Markdown 文档里,主流渲染器都认。
跨页表格是另一个老大难。检测方法是:页面底部的表格块如果末行是空的或者没有闭合框线,且下一页顶部有表格块,就尝试合并。合并的前提是列数一致且表头结构匹配。列数不一致的,宁可拆成两个表加一句说明,也不要强行拼。
| 表格类型 | 推荐方法 | 准确率区间 | 主要风险 |
|---|---|---|---|
| 有完整框线 | 形态学找线 + 单元格切分 | 92% 到 97% | 框线断裂、手写批注 |
| 无线但对齐规整 | 坐标聚类划行列 | 80% 到 90% | 单元格折行错位 |
| 合并单元格多 | 表格结构识别模型 | 78% 到 92% | 复杂嵌套还原不准 |
| 跨页续表 | 列数匹配 + 表头比对 | 60% 到 80% | 列数变化、表头重复 |
4.2 公式:定位比识别重要得多
行内公式和行间公式要分开对待。行内公式混在正文里,位置小、高度低,OCR 往往能勉强识别出主体字符,但上下标会丢,或者把 ∑、∫ 识别成奇怪的字符。行间公式居中独立成行,通常带编号,如果整块送正文 OCR,会输出一堆乱码,而且会把前后段落切断。
我的处理顺序是:先用版面检测或者规则(居中对齐、上下留白大、含数学符号密度高)定位公式区域,把它从正文流里"挖掉",正文 OCR 时屏蔽这块区域,然后对公式块单独处理。单独处理有两条路:简单的是整块裁图,在输出里插入图片引用,读者能看,语义保住了但不可搜索;进阶的是用公式识别模型转 LaTeX,质量看公式复杂度,简单单行公式效果可以,多行对齐的矩阵、分段函数基本没法看。
有个低成本技巧:如果文档里公式不重要,只是不想让它污染正文,那就只做定位屏蔽,输出里放一个[公式]占位符。实测这种做法对下游任务(比如检索、摘要)的影响远小于输出乱码。
4.3 图注归属和图内文字
图注这个问题被严重低估。很多解析工具输出的是,图注文字丢了,读者根本不知道这张图是什么。图注的归属判断规则是:在图的块下方(表格是上方)找垂直距离小于 1.5 倍行高的文本块,且水平区间的重叠比例超过 50%,判定为图注,输出时紧跟在图片下方。
图内的文字(坐标轴标签、图例、数据标注)要不要识别,取决于你的用途。如果只是做全文检索,图内文字价值不大而且识别噪声高,不建议处理。如果要做图表数据抽取,那得单独走图表理解模型,那是另一个话题了。折中做法是:图内文字识别结果单独存一个字段,不混进正文。
还有个细节:子图标注。一张大图里分 (a)(b)(c) 三个子图,各有各的说明文字,这些文字的位置是分散的。处理时按子图边界框分组,每个子图配自己的说明,别全塞到总图注里。
5. 完整流水线落地:从整本 PDF 到可用的结构化输出
5.1 工程结构和一个容易被忽略的缓存设计
目录结构我习惯这样分:
project/ data/ pdf/ # 原始 PDF pages/ # 渲染出的页面图 cache/ # 中间结果缓存 out/ # 最终输出 conf/ layout.yaml # 版面/渲染参数 src/ classify.py render.py layout.py ocr_block.py table.py order.py postprocess.py assemble.py logs/缓存这件事,一定要在第一天就设计进去。整本书跑一遍动辄几十分钟,调后处理规则时如果每次都要重跑 OCR,效率低到崩溃。缓存粒度按"页哈希 + 环节"来:对每页的图像内容算一个哈希,加上 DPI 和模型版本号,作为缓存键。渲染、版面检测、块级 OCR 各自缓存,调后处理的时候直接读缓存,秒级迭代。
import hashlib, json, os def page_key(pdf_path, pno, dpi, stage): h = hashlib.md5() h.update(os.path.basename(pdf_path).encode()) h.update(f"{pno}|{dpi}|{stage}|v3".encode()) return h.hexdigest() def cached(stage, key, fn): path = f"data/cache/{stage}_{key}.json" if os.path.exists(path): with open(path, encoding="utf-8") as f: return json.load(f) result = fn() with open(path, "w", encoding="utf-8") as f: json.dump(result, f, ensure_ascii=False) return result5.2 主循环和批处理参数
主循环逻辑不复杂,但有几个参数要注意。批大小(batch size)不要按页数定,要按像素总量定,因为不同页的尺寸差异很大。我的经验值是单次推理的总像素不超过某个上限,超过就拆批,否则显存炸。版面检测模型可以批大一点,OCR 模型的 batch 一般 4 到 8 比较稳。
def process_book(pdf_path, dpi_default=300): doc = fitz.open(pdf_path) results = [] for i, page in enumerate(doc): kind = classify_page(page) if kind == "text": blocks = extract_from_text_layer(page) else: dpi = 400 if need_high_dpi(page) else dpi_default img = render_page(doc, i, "data/pages", dpi=dpi) layout = cached("layout", page_key(pdf_path, i, dpi, "layout"), lambda: detect_layout(img)) blocks = [] for b in layout: if b["type"] in ("header", "footer", "number"): continue if b["type"] == "table": b["content"] = parse_table(img, b) elif b["type"] == "equation": b["content"] = "[公式]" elif b["type"] == "figure": b["content"] = crop_figure(img, b) else: b["content"] = ocr_text_block(img, b) blocks.append(b) blocks = order_page(blocks, page.rect.width, page.rect.height) results.append({"page": i, "blocks": blocks}) return postprocess_book(results)5.3 输出格式与质量监控
输出我一般同时给两份:一份 Markdown 给人和下游文本模型用,一份 JSON 带每块的坐标和类型给需要回溯的场景用。JSON 里至少保留页码、块序号、块类型、bbox、识别文本、置信度这几个字段。置信度很有用,低于阈值的块单独标记出来,人工校对时优先看这些。
质量监控不用搞太复杂,看三个指标就够:每页字符数、每页块数、低置信块占比。三者在整本书里应该基本平稳,突然掉到平均值 30% 以下的页面大概率出了问题,可能是图片页、空白页,也可能是渲染失败。把异常页自动挑出来输出一个清单,人工只看这几十页,比全本翻一遍快得多。
评估准确率的话,建议人工标注 15 到 20 页作为黄金集,标出阅读顺序和文本内容。顺序准确率可以简单算"相邻块对顺序正确的比例",不需要复杂指标。文本准确率用编辑距离算字符级准确率就够了。这两个数在手,后面每次改规则都能量化对比,避免"感觉好像好了一点"这种模糊判断。
6. 常见问题与排查技巧实录
6.1 高频故障速查表
| 现象 | 大概率原因 | 处理方式 |
|---|---|---|
| 左右栏文字串行 | 未做版面切分,整页直送 OCR | 加版面检测,或至少做投影切栏 |
| 通栏标题被切进某一栏 | 未识别通栏块,按等宽双栏处理 | 宽度超过页面 65% 的块标记为通栏 |
| 表格数字混入正文 | 表格区域未隔离 | 版面检测后按类型分流 |
| 公式输出乱码 | 公式块送进了正文 OCR | 先定位再屏蔽,宁保留图片 |
| 段首和段尾被拆成两段 | 跨栏续接未处理 | 判断前块末字符非终止标点则合并 |
| 每页开头都出现刊名 | 页眉未剔除 | 跨页高频文本检测后批量删除 |
| 脚注识别率极低 | 字号太小,像素高度不足 | 局部放大到 400 到 500 DPI 单独识别 |
| 输出里有重复段落 | 检测框重叠导致重复识别 | 相邻块文本相似度大于 0.9 去重 |
| 小字号表格线断裂 | DPI 不够或二值化过度 | 提到 400 DPI,改用灰度图 |
| 扫描页整体倾斜 | 未做方向检测 | 加倾斜角检测,超过 0.5 度做矫正 |
6.2 几条反直觉的实测经验
第一,分辨率不是越高越好。我试过同一批扫描件分别用 300、400、600 DPI 跑,600 DPI 的字符准确率反而比 400 DPI 低了将近 1 个百分点。原因是过高的分辨率把纸张纹理和压缩噪点也放大了,OCR 模型对这类噪声敏感。400 DPI 加轻度去噪是甜点区,再往上收益递减甚至为负。
第二,块级识别比行级识别稳。早期我是把每个文本块再切成行,逐行送 OCR,觉得这样更精确。实测下来整块送的效果更好,因为模型能利用上下文,行与行之间的语义关联帮它纠错。切行只在一种情况下必要:块的尺寸超出了模型输入上限,这时候才按行切,切完还要保证行间距信息不丢。
第三,双栏里最难的不是分栏,是跨栏元素。分栏本身用坐标聚类就能解决,真正头疼的是那些横跨两栏的元素:通栏表格、通栏图、跨栏的公式组。判断通栏不能只看宽度,还要看它和上下文的间距——通栏元素通常上下留白明显大于普通行距,这个特征比宽度更可靠。我现在的做法是宽度和上下留白两个条件加权,权重根据文档类型调。
第四,有文本层不代表能用。这条前面提过,但值得再说一遍。我遇到过一批电子版 PDF,文本层完整、字符准确率 100%,但顺序完全是乱的,因为导出时按对象创建顺序写入。判断方法是抽三页看看抽出来的文本读不读得通,读不通就必须走 OCR 加版面还原,别偷这个懒。
第五,后处理规则的顺序会影响结果。比如"页眉剔除"必须在"段落合并"之前,否则页眉文字会被当成段落的一部分合并进去,删都删不掉。"连字符处理"必须在"段落合并"之前,否则合并后连字符位置找不到。"中英文加空格"必须放在最后,前面的步骤都会影响词边界。这个顺序我调了不止一次,每次调整都要重新跑黄金集。
第六,别忽略页面旋转和镜像。扫描件里 90 度、180 度旋转的页面并不罕见,尤其是一本书里夹了几页横排的图表页。方向检测不做,这几页的版面检测和识别全废,而且报错信息不明显,容易被忽略成"这一页识别效果不好"。加一个方向分类,成本很低,收益很高。
6.3 一个完整的排查思路示例
假设你发现某本 300 页的书,输出里第 80 到 120 页的阅读顺序大面积错乱。不要急着改规则,按这个顺序查:
先 dump 出这几页的版面检测结果,把块框画到图上存下来,肉眼看一下框对不对。如果框本身就把左右栏合并成一个大框,那是检测模型的问题,调阈值或者换模型;如果框是对的但顺序错了,进第二步。
第二步,单独跑排序函数,打印出每页的块列表和排序结果,看是带分割出错还是带内分栏判断出错。常见情况是通栏块漏检,导致本该切开的页面被当成一个整体,双栏判断失败,退化成按 y 排序,于是左右栏交错。
第三步,看这几页有没有特殊的版面元素,比如横跨两栏的大表格、旋转页、整页图。定位到具体的触发条件,针对性地加规则,而不是全局调参。全局调参会把本来正常的页面搞坏,这个教训我吃了不止一次。
按这个流程走,大部分顺序问题十分钟内能定位到具体环节,比盲目换模型高效得多。
最后说个实际体会:复杂版面 OCR 这件事,没有一劳永逸的方案,能做到 95% 就已经是很高的水平了,剩下的 5% 往往来自文档本身的特例。所以工程上与其追求把准确率推到极致,不如把"出错后可发现、可定位、可修复"做扎实——异常页自动标记、中间结果全量留存、缓存支持秒级重跑,这三件事的价值远大于再调两个点的准确率。