事情是这样的:我临时接到一个需求,要把一批扫描版PDF转成文字版PDF。文件本身是扫描件,每一页都是一整张图片,复制不了、检索不到,更别提后面做语料整理。这个需求听起来简单,真正动手就会发现水很深——不是装个OCR把文字识别出来就完事,还要解决“文字识别出来后怎么放回PDF里”的问题。我最终选了omnicoder-9b模型来写这套转换程序,前后在环境搭建、坐标映射、字体嵌入上踩了一圈坑。这篇文章把完整思路、可运行代码和避坑经验都整理出来,给同样要做扫描版PDF转文字版PDF的同学一份能直接抄作业的参考。
1. 扫描版PDF的真实痛点:这本书能搜,但这份文件不能
1.1 扫描PDF的本质:每一页都是一张图
先明确一个概念:扫描版PDF(也叫“图片型PDF”)跟文字版PDF在结构上完全是两回事。前者每一页就是一张完整图片,PDF格式对这页内容的理解是“这是一张图,不是一串文字”。所有内容都以像素形式存在,没有文本层。
这个特性带来三个连锁问题。一是检索失效,Ctrl+F在扫描PDF里搜不出任何东西,因为页面里没有任何文字数据可供匹配。二是复制摘录失效,你想引一句话,只能手动重新打一遍。三是后续处理困难,想做文本分类、切词、语义检索,全都无从下手,因为程序读到的只有像素。
很多人对PDF的期待是“它能搜、能复制”,但扫描PDF天然不具备这个能力。这个需求不是“美化排版”,而是给PDF补上缺失的文本层。理解这一点,后面的技术选型才不会跑偏。
1.2 转换目标不是换排版,而是增加隐形文本层
这里有个常见的错误思路:很多人一听“转成文字版PDF”,第一反应是“把扫描图片识别成文字,然后重新排版生成一个干净的PDF”。
这个思路在需求层面就错了。重新排版意味着丢掉了原页面所有视觉信息——字体、版式、插图位置、页眉页脚、手写批注的坐标关系全部没了。对科研资料、古籍扫描、档案材料来说,丢掉原版面是不可接受的。你需要的不是一个“长得像文档”的PDF,而是“看起来跟原来一模一样,但文字可以搜索、可以复制”的PDF。
正确做法是:保留原始扫描图片作为页面背景,OCR识别出的文字按坐标放回到对应位置,生成一层透明的、肉眼不可见的文本层。这层文本不影响阅读,但承担了搜索和复制的功能。
这个思路可以类比成视频的“字幕轨道”——视频画面完全不变,字幕是独立的一层,关闭时看不到,开启时就能读取。扫描版PDF转文字版PDF,本质就是在原有图片上叠加一条文字轨道,让PDF阅读器能检索到页面里的文字内容,同时复制出来的是干净文字而不是图片。
理解了“加文本层”这个核心,接下来才到了真正有技术含量的问题:用什么模型识别文字和坐标,怎么把识别结果精确映射回PDF页面。
2. 为什么是omnicoder-9b:从两阶段OCR到端到端识别
2.1 老牌OCR工具为什么做不好这类需求
在选型之前,我先想清楚了一个问题:市面上OCR工具一大堆,为什么还要盯上omnicoder-9b这种大模型?
先说传统方案。Tesseract是老牌开源OCR,胜在轻量,对规整印刷体效果尚可,最大的问题是版面理解能力弱。遇到多栏排版、表格混排、页眉页脚交错的扫描件,它经常把两栏文字混在一起识别,输出的文本顺序是乱的。而且传统OCR的输出以纯文本为主,坐标信息要么丢失、要么格式不规整,想拿它做PDF文本层,还得自己拼版面分析,工作量不小。
另一类方案是PaddleOCR这类深度学习工具包,它的检测和识别模型分两步走:先做文本框检测,再把每个框裁剪出来单独识别。问题在于两阶段流程天然割裂了上下文——一个文本框识别错了,不会参考前后文本去纠正;面对跨栏的长句,检测框之间的拼接逻辑也很容易出问题。更别提部分场景还依赖额外的版面分析模块,配置起来繁琐。
对比之下,omnicoder-9b是端到端设计的OCR模型,它把版面分析、文字检测、文字识别、结构化输出合并成了一个完整流程,特别适合“我要给PDF做文本层”这种既要文字、又要坐标、还要保版面的场景。
2.2 omnicoder-9b把版面、识别、结构化输出合并成一件事
omnicoder-9b核心思路是直接把整页图像作为输入,通过编解码结构输出结构化的识别结果。它不再区分“检测”和“识别”两个模块,而是让模型在理解整页版面的同时,同步生成文字内容和位置信息。
具体到模型机制,可以这样理解:图像被切分并编码成图像特征的序列,解码器在生成文字时不仅依赖视觉特征,还能感知整页的版面结构——哪里是标题、哪里是正文、哪里是表格、哪里是页脚。这种全局感知能力,是传统两阶段OCR不具备的。
我对这个感受最深的一点是,它对长文档和复杂版面的稳定性明显更好。传统OCR对单行文字的识别率可能也很高,但一旦遇到整页多栏、图文混排、文本框错位,错误率就急剧上升。omnicoder-9b因为是在“理解整页”的基础上做识别,文本顺序、栏位划分、段落归属基本不会乱。
另外,它既支持纯文本输出(Markdown风格),也支持带坐标的细粒度输出(词级/行级),后者正好是构建PDF文本层需要的格式。对我们是“一次推理,两份成果”——既可以生成可搜索的PDF,也可以顺手导出干净的Markdown文本做语料。
2.3 部署成本:一张普通显卡就能跑
很多同学一听到9B参数量就慌,觉得得弄个多卡服务器才能跑。实际部署下来,9B这个量级并没有想象中那么吃资源。
模型权重文件大约在18GB左右(半精度),这个体量一张24GB显存的显卡就能完整加载。如果你用的是16GB显存的卡,可以通过量化方式把模型压到10GB以内,跑推理完全没问题。我自己平时用的是单张消费级显卡,配合量化版本的模型,单页扫描件的推理时间在几十秒量级,批量处理还能接受。
和传统方案做个横向对比,会更直观:
| 方案 | 版面理解 | 坐标输出 | 多语言与中文 | 部署成本 | 文本层友好度 |
|---|---|---|---|---|---|
| Tesseract | 弱,需额外版面分析 | 有限 | 中等 | 很低 | 一般 |
| 两阶段深度学习OCR | 中等 | 有 | 较好 | 低 | 一般,需组装 |
| omnicoder-9b | 强,端到端 | 词级/行级 | 好 | 中,单卡可跑 | 高 |
选了omnicoder-9b之后的下一个问题就是把转换程序完整写出来。这里我想先说清楚一点:模型本身只解决“识别”问题,从PDF到图片、从图片到坐标、从坐标到文本层,每一步都需要程序层面细致处理,缺一环都不行。
3. 整条转换链路与核心代码
3.1 第一步:把每页PDF渲染成图片
模型的输入是图片,而PDF本身是矢量容器,所以第一步需要把每一页渲染成像素图像。这一步用PyMuPDF(Python里通常导入为fitz)非常方便,它能直接打开PDF并把页面渲染成高分辨率图片。
这里有一个关键参数:DPI。渲染DPI决定了OCR识别率的上限。如果DPI太低,小字号的文字在图像上只有几个像素,模型再强也识别不出来。我一般默认取300dpi,这个分辨率对大多数扫描件已经比较充足。如果原PDF里的扫描图片本身只有150dpi,用300dpi渲染并不会凭空增加信息,这时候按原图实际分辨率渲染即可,避免无意义的资源消耗。
代码很简单:
import fitz doc = fitz.open("input.pdf") page = doc[0] # page.rect.width 是PDF页面宽度(单位:点pt),1英寸=72pt dpi = 300 scale = dpi / 72 mat = fitz.Matrix(scale, scale) pix = page.get_pixmap(matrix=mat, colorspace=fitz.csGRAY) # pix.width / pix.height 就是渲染出来的图像像素尺寸 # pix.tobytes("png") 可以拿到PNG格式的字节流我在这里把色彩空间设成了灰度(fitz.csGRAY)。扫描件一般不需要彩色信息,灰度图能显著减少内存占用和推理耗时。计量一下:一张A4页面在300dpi下,RGB图像约26MB,灰度只有约8.7MB,后续推理时间也能降下来。
3.2 第二步:让模型识别出“文字+坐标”
渲染出图片后,调用omnicoder-9b进行推理。模型接口抽象掉具体实现后,核心是这样一个调用:
from omnicoder import OmniCoderPipeline pipeline = OmniCoderPipeline.from_pretrained("omnicoder-9b") result = pipeline.ocr( pix.tobytes("png"), format="word-level", # 返回词级坐标 language="auto", )返回结果的大致结构是这样:
{ "words": [ { "text": "目录", "bbox": [212.0, 545.0, 384.0, 577.0], "score": 0.99, "line_id": 3 }, { "text": "第一节", "bbox": [212.0, 584.0, 366.0, 612.0], "score": 0.98, "line_id": 4 } ] }其中bbox是图像像素坐标系下的坐标:[左上角x, 左上角y, 右下角x, 右下角y]。这个坐标和PDF页面坐标不同,不能直接拿来写入PDF,必须先做坐标映射。
词级输出是构建文本层最稳妥的选择。段落级输出虽然看起来简单,但在双栏版面中容易出现跨栏拼接问题;词级输出即使某一行拼接错位,也不会影响搜索定位。
3.3 第三步:坐标映射与隐形文字层落盘
这是整个程序最容易出问题的一步,一定要理解两套坐标系的关系。
PDF页面坐标系以“点”(pt)为单位,原点在页面左上角,y轴向下?不对——PDF规范里原点在左下角,y轴向上。图像坐标系则是原点在左上角,y轴向下。两套坐标系的原点和方向都不一样,转换公式必须处理这个差异。
假设页面渲染DPI为dpi,页面宽度为pdf_w(单位pt),页面高度为pdf_h。图像中某个边界框坐标为[x0, y0, x1, y1](单位像素),转换到PDF坐标的公式如下:
scale = 72.0 / dpi # 每像素对应的pt数 pdf_x0 = x0 * scale pdf_y0 = pdf_h - y0 * scale # 注意y方向翻转 pdf_x1 = x1 * scale pdf_y1 = pdf_h - y1 * scale这里核心是y轴翻转:图像坐标系的原点在左上角,PDF的原点在左下角,所以y必须用页面高度减去。我举个例子:300dpi下,一张A4页面高度约842pt,某文字框在图像坐标系里的y0=1200像素,那么PDF坐标系下y0就是842 - 1200 * 72/300 = 542pt。如果忘了翻转,所有文字都会被放到编辑区外或镜像位置,搜索时文字和图像对不上。
坐标转换完毕,就到了插入文本层的环节。PyMuPDF的insert_text方法支持render_mode=3,这是PDF标准的“不可见文本”渲染模式,文字会被写入页面内容流,但肉眼不可见,搜索和复制又完全正常。
另一个关键细节是字体。内置的Helvetica等标准字体不支持中文编码,直接写中文字符会乱码或者丢失。必须显式嵌入一款支持中文的字体,比如思源黑体:
font_name = page.insert_font( fontname="NotoSans", fontfile="/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc" ) page.insert_text( fitz.Point(pdf_x0, pdf_y0), word["text"], fontsize=font_size, fontname=font_name, render_mode=3, )这个坑我在第一次测试时就踩了:当时用的内置字体,搜索中文一个字都搜不到,后来查了内容流才发现文字编码根本没有映射Unicode。嵌字体这一步省不了。
4. 完整可运行的转换程序
4.1 依赖清单
把上面几个环节串起来,就是一个完整的转换程序。依赖如下:
- Python 3.10 以上
- PyMuPDF(导入名
fitz),负责PDF解析、页面渲染、插入文本层 - omnicoder-9b推理库及模型权重(从模型仓库下载)
- 一个中文字体文件(如Noto Sans CJK,用于嵌入文本层)
安装命令大致如下:
pip install pymupdf pip install omnicoder-9b # 安装中文字体(不同系统环境命令不同,略)需要注意的是,omnicoder-9b推理加速默认走CUDA,如果没有可用GPU,CPU推理也能跑,但速度会慢很多。批量处理大量扫描件时,建议至少有一张支持半精度推理的显卡。
4.2 核心类代码
我整合了一个可以直接落地的类。代码里做了几件事:初始化模型、按页渲染、调用OCR、坐标映射、插入隐形文本层、输出新PDF。
import argparse import logging from pathlib import Path import fitz from omnicoder import OmniCoderPipeline logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") LOGGER = logging.getLogger(__name__) CJK_FONT_CANDIDATES = [ "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", "/usr/share/fonts/truetype/wqy/wqy-microhei.ttc", "C:/Windows/Fonts/msyh.ttc", ] class ScanPdfConverter: def __init__(self, model_dir, dpi=300, device="cuda"): self.dpi = dpi self.device = device self.pipeline = OmniCoderPipeline.from_pretrained(model_dir) if device == "cuda": self.pipeline.to("cuda") def _render_page_to_png(self, page): scale = self.dpi / 72 mat = fitz.Matrix(scale, scale) pix = page.get_pixmap(matrix=mat, colorspace=fitz.csGRAY) return pix def _find_cjk_font(self): for path in CJK_FONT_CANDIDATES: if Path(path).exists(): return path raise FileNotFoundError("No CJK font found, please install one.") def _insert_text_layer(self, page, words, font_path): pdf_h = page.rect.height scale = 72.0 / self.dpi font_name = page.insert_font(fontname="CJK", fontfile=font_path) for word in words: x0, y0, x1, y1 = word["bbox"] score = word.get("score", 1.0) if score < 0.4: continue pdf_x = x0 * scale pdf_y = pdf_h - y0 * scale font_size = max(1.0, (y1 - y0) * scale * 0.9) page.insert_text( fitz.Point(pdf_x, pdf_y - font_size * 0.18), word["text"], fontsize=font_size, fontname=font_name, render_mode=3, ) def convert(self, src_path, dst_path): doc = fitz.open(src_path) font_path = self._find_cjk_font() for page_num, page in enumerate(doc): pix = self._render_page_to_png(page) result = self.pipeline.ocr( pix.tobytes("png"), format="word-level", language="auto", ) words = result["words"] self._insert_text_layer(page, words, font_path) LOGGER.info("page %d done, %d words inserted", page_num + 1, len(words)) doc.save(dst_path, garbage=4, deflate=True) LOGGER.info("saved to %s", dst_path)4.3 命令行入口与运行观察
再加一个极简命令行入口:
if __name__ == "__main__": parser = argparse.ArgumentParser(description="convert scanned pdf to searchable pdf") parser.add_argument("--input", required=True, help="input scanned pdf") parser.add_argument("--output", required=True, help="output pdf path") parser.add_argument("--model-dir", required=True, help="omnicoder-9b model dir") parser.add_argument("--dpi", type=int, default=300) parser.add_argument("--device", default="cuda", choices=["cuda", "cpu"]) args = parser.parse_args() converter = ScanPdfConverter(model_dir=args.model_dir, dpi=args.dpi, device=args.device) converter.convert(args.input, args.output)运行命令:
python convert_scanned_pdf.py --input scanned.pdf --output searchable.pdf --model-dir ./omnicoder-9b运行日志会逐页显示插入的词数。我观察到的典型情况是:一页A4扫描件大约识别出300到700个词,单页推理在十几秒到一分钟之间。转换完的PDF用阅读器打开,文件视觉上跟原扫描件毫无差异,但搜索栏里任意输入一个关键词,能直接高亮命中,复制出来的文字也是干净的。
5. 实战中躲不开的几个坑
5.1 DPI不是越高越好
我先讲一个容易让人纠结的问题:DPI到底取多少。
很多人觉得DPI越高识别越准,所以直接上600。实际跑下来你会发现,从300提到600,渲染像素面积翻4倍,推理耗时和内存开销也成倍增长,识别率的提升却很有限。扫描件原始信息量本来就不高,如果原图只有200dpi,用600dpi渲染出来的只是插值放大,机器不会因此“看到”更多细节。
我的建议是:常规扫描件统一用300dpi;如果原PDF小字号密集、字迹模糊,再提高到400dpi试一页对比效果;超过600dpi基本没有收益,除非你遇到的是特别小的印刷字体且原始扫描分辨率本身很高。
还有一个相关优化:渲染时转灰度。彩色扫描页里面的红色印章、彩色logo对文字识别帮助不大,但RGB图像比灰度图多占3倍内存。用colorspace=fitz.csGRAY之后,页面背景里的彩色信息会被丢弃,个别场景下反而能减少背景纹理对模型的干扰。
5.2 版面乱、双栏交叉怎么处理
扫描件的版面问题比想象中多:两栏论文、三栏报纸、页眉页脚混排、多级标题嵌套。omnicoder-9b在版面理解上已经比较强,双栏文本基本不会跨栏拼错,但我在处理一批老杂志时还是遇到了顺序不稳定的情况。
具体表现是:单行文字内部识别正确,但文本层按行插入PDF后,选中文本从上往下复制时,顺序会把左栏第一行、右栏第一行、左栏第二行这样交叉排列。原因是OCR推理输出行顺序本身受阅读顺序影响,而PDF文本层只是按坐标摆放文字,不负责记录阅读逻辑。
这个问题的处理思路有两个层面。一是插入文本层时按行级别而不是词级别,让复制出来的文本至少在行内是连续的;二是如果你的下游任务更看重文本顺序,可以调用模型的Markdown输出模式,单独导出一份线性阅读文本,与可搜索PDF分开使用。
我自己的习惯是:可搜索PDF用来阅读和检索,Markdown纯文本用来做后续语料处理。两条线各司其职,不强行把版面逻辑塞进文本层。
5.3 基线偏差与坐标偏移
插入文字的位置和原图像上的文字位置对不齐,是文本层做得久了必然遇到的问题。原因在于OCR模型输出的边界框描述的是“文字整体占用的矩形区域”,而PDF插入文字时默认以“基线”作为定位点。
基线是什么?可以简单理解为文字底部那条对齐线。英文里字母底部坐在基线上,中文则通常在边界框内偏下一些。如果你直接把边界框的左上角当作插入点,插入的文字会整体向右上偏移几像素;如果直接拿左下角,又会向下偏移。
要调整这个偏移,一个是靠insert_text传点时对y坐标做微调。我在代码里用的经验值是y0 - font_size * 0.18,把插入点从边界框顶部向下挪一点,让文字落在视觉对齐的位置。这个系数不是普适的,不同字体、不同模型输出会有轻微差异。
另一个层面是:文本层本身是透明的,偏移几个像素对阅读完全无影响,影响的只是“复制出来的文字在粘贴后如何断行”。如果对这个问题比较敏感,最直接的办法是临时把render_mode改成0,把文字显式绘制在页面上,截个图对比文字是否覆盖原位置,肉眼调完系数再改回3。这个方法虽然土,但很管用。
5.4 批量任务的内存与显存控制
批量处理几百页PDF时,最容易出问题的反而不是识别质量,而是内存和显存管理。
一个典型错误是:一上来把所有页面渲染成一堆PNG图片存到内存列表,然后逐页推理。假设300页PDF、每页灰度图8.7MB,光图片就占掉2.6GB内存,再叠加模型权重、中间特征,很容易内存溢出。
正确做法是逐页处理:打开PDF、渲染一页、推理一页、把结果写入PDF、释放这一页的临时对象,再接下一页。我的代码里就是按这个逻辑来的,所以内存占用基本稳定在一个固定水位。
显存方面也有一个经验:在单卡环境下,多进程并行加载模型推理并不会显著加速,反而会因为每个进程重复加载18GB权重而显存爆炸。正确做法是单进程内逐页推理,或者只对“图像渲染”这个CPU部分做并行,OCR推理保持串行。真要大规模提速,更合理的方向是部署多显卡服务,用任务队列分发页面。
还有一个小优化:使用量化版模型时,显存占用能降一半,推理速度甚至可能更快,因为访存量变小了。我实际转换档案时,int8量化版本在16GB显存的卡上跑得很稳,基本没有因为显存不足报错过。
6. 效果验收与后面的扩展方向
6.1 怎么验证转换质量
程序跑完不能直接交付,必须做验收。我一般从三个角度验证。
第一是搜索验证:打开转换后的PDF,随机挑20个原文中比较生僻的词语,逐个用阅读器全文搜索,确认每个都能命中。生僻词比高频词更能反映识别真伪,因为高频词有可能误打误撞匹配到。
第二是复制验证:复制一段包含中文标点、数字混合的文字,粘贴到文本编辑器里,看看字符是否完整、顺序是否正常、有没有被吞字或插入乱码。这一步主要检查文本层的字体编码是否正确,如果字体嵌入有问题,中文完全无法复制。
第三是视觉对位:抽几页截图,把页面放大到150%,观察隐形文本层对应的区域是否与图像文字大致重叠。这个检查不用很精细,只要确认没有文字层被插到页面外、没有整块偏移,就算合格。
如果有条件,还可以做量化的字符错误率评估:用工具导出PDF文本层内容,和原始文档人工比较一段,数一下错字比例。我手头这批材料的抽样结果,印刷体的错误率基本控制在百分之几以内,手写部分另说,那是另一场战争。
6.2 从“能搜PDF”到检索与数字化
转换程序的产出物本身就可以直接交付使用,但如果你愿意多走一步,这套体系能延伸出不少玩法。
一个很自然的扩展方向是全文检索索引。所有扫描PDF都转成可搜索版本之后,可以用通用工具对整批文件建立索引,这样几百份PDF可以像本地资料库一样一键全文搜。对需要经常翻阅历史资料的人来说,这一步能把“文件躺在硬盘里吃灰”变成“随时调取关键信息”,体验提升非常明显。
另一个常见扩展是接RAG(检索增强生成)流程。扫描PDF转出文本层后,可以用工具把每一页的文本单独导出,按页切片、向量化、存入知识库,再对接问答模型。整套流程做下来,相当于把你手头那批“死图片”变成了可以被大模型检索引用的“活文本”。我之前测试过用这批材料做问答,模型引用原文的准确率高了很多,因为它不再依赖模糊猜测,能直接检索精确片段。
如果处理的是批量档案,还可以在转换程序外面包一层自动化:扫描目录、逐文件转换、按文件名和页数输出索引清单、把结果归档到目标目录。整套跑起来之后,从收到扫描件到获得可检索文本库,中间不需要人工干预,这是最省心的工作流。我当前这批几百页的材料就是用这种批处理方式跑完的,整个过程很少需要盯屏。
最后再分享一个小技巧:如果扫描页边缘有大量黑边或杂色,在渲染图片时可以用PyMuPDF的裁切参数先把边角裁掉再送进模型,识别率会明显改善。我试过同一页带黑边和裁边后的对比,裁边后不仅推理速度变快,误识别还少了很多。黑色的边缘会让模型额外“脑补”出一些不存在的文字区域,裁掉这些干扰信息,整个转换程序的质量会更上一个台阶。