扫描书高保真夹心 OCR——把普特南和罗蒂变成'原版一模一样+全文可检索'的 PDF¶
🌐 语言 / Language:中文 · English
Provenance(来源与元数据)
| id | marginalia-014 |
| title | 扫描书高保真夹心 OCR——把普特南和罗蒂变成'原版一模一样+全文可检索'的 PDF |
| date | 2026-08-22 |
| published | 2026-08-22 |
| kind | analysis(工程复盘) |
| issue | 37 |
扫描书高保真夹心 OCR¶
工程复盘。问题:扫描版学术书既不能检索也不能精准复制——怎么在不动一个像素的前提下给它装上文字层?结论先放开头:用"夹心"架构——原 PDF 页上叠
render_mode=3的逐字不可见文本层,图像流零重编码(MD5 级验收 0 差异);识别用 RapidOCR 打 DirectML 补丁(~1.0 页/秒,CPU 的 10 倍);防假空格的关键是让每个字符的 fontsize 恰好等于它的分配步进。两本书验收通过:普特南无损档 66MB + 压缩增强档 21.6MB,罗蒂无损档 23.9MB(CCITT G4 已是最优,不重压)。过程中踩了七个真实的坑,最贵的一个:extract_image()不应用 PDF/Decode数组,反相页直接变黑底白字。
一、需求与约束¶
手头两本扫描书的痛点不同:
- 普特南:283DPI 灰度 JPEG,完全没有文本层——不能检索、不能复制;
- 罗蒂:更糟,流传的旧 OCR 版文本层稀烂,复制出来是
CET |AREA BG BAB)这样的乱码加竖线假分词。
目标三连:原版内容一模一样(视觉零损失)、文字可精准选中复制(且复制不出多余空格)、文件尽可能小。这三条彼此牵制:任何重编码都破坏第一条;文本层做粗了破坏第二条;而"高保真"与"小体积"天然对冲——除非分书施策。
二、方案:夹心架构,两阶段解耦¶
build_ocr_pdf.py ocr 原 PDF → 灰度渲染(长边 2400px)→ RapidOCR → 行框+文本+置信度 → JSONL 缓存
build_ocr_pdf.py apply 原 PDF 页上叠逐字不可见文字层(render_mode=3)→ save(garbage=3) → 无损档
两个决定值得展开:
图像流零重编码。 不生成新 PDF,而是在原 PDF 的页对象上追加文字层后另存。save(garbage=3, deflate=True) 只整理结构、重压元数据流,不触碰图像流。验收脚本逐页比对图像流 MD5:0 差异;再抽样比对渲染像素:完全一致。"原版一模一样"由此从口号变成可断言的性质。
缓存与组装解耦。 OCR 结果落 JSONL(每行一个页记录),组装阶段秒级完成、可反复重跑——后来四次调整文字层排版(修空格、修阅读顺序)都没有重新识别过一个字。中断续跑也免费获得:重跑时跳过已缓存页。
三、三个关键技术决定¶
1. 字符级定位 + "fontsize=步进",根治假空格¶
RapidOCR 给的是行级框。要让选中/复制精确到字,得把行切成单字摆放:按 CJK 全宽 1.0、Latin/数字 0.55、标点 0.45 的宽度配比,把行的总宽按权重切分给每个字符。
第一版的坑:字号取 min(框高×0.72, …),结果字形比字距窄,阅读器提取文本时按"字形间隙"推断空格,复制出大量假空格(用户实测发现)。修复:让每个字的 fontsize 恰好等于它分到的步进宽度——CJK 字形的 advance 恰等于 fontsize,于是"字形步进=字符间距",间隙恒为零,任何阅读器都无从推断。再把识别串里夹在两个汉字之间的模型假空格(如"李幼 蒸译")在组装时正则删除。双保险之后,复制文本干净了。
2. DirectML 猴补丁,GPU 提速 6–10 倍¶
rapidocr_onnxruntime 1.2.3 的会话工厂只认 CPU/CUDA 两个 EP,机器上的 RTX 4060 完全使不上;而环境里已装的 onnxruntime-directml 明明带着 DmlExecutionProvider。解法是重写 OrtInferSession.__init__,把 provider 列表换成 DML:
| 设备 | 速度 | 备注 |
|---|---|---|
| CPU 全核(默认) | 0.10 页/秒 | 吃满所有核,机器卡到没法用 |
| CPU 限 4 线程 | 0.18 页/秒 | 防卡机兜底 |
| DirectML | ~1.0 页/秒 | CPU 基本空闲;输出与 CPU 逐字一致 |
521 页的书从 ~87 分钟缩到 ~9 分钟,而且全程电脑可用。
3. 二值书必须先灰度抗锯齿渲染¶
罗蒂书是 1-bit 二值扫描,直接喂 OCR 识别稀烂(旧版失败的根因)。锯齿边缘破坏检测与识别,必须先经 PyMuPDF 以长边 2400px 渲染成灰度——放大过程天然产生抗锯齿灰阶,识别质量立刻恢复正常(置信度均值 0.71→0.88)。
四、七条踩坑实录(按代价排序)¶
extract_image()不应用 PDF/Decode数组。罗蒂书的部分 1-bit 图带[1 0]反相解码;重构出的 PNG 极性是反的,直通嵌回就是黑底白字(用户翻开实测发现)。铁律:永不直通extract_image()的字节;极性/编码判断一律基于原生get_pixmap渲染——渲染永远正确应用 Decode。- 二值源书不要再压缩。为了救体积先试直通(PNG 容器比 G4 裸流胖 ~30KB/页,19.9→36MB),再试自家重编码(去噪+PIL optimize,自以为 0.65×)——最后发现 0.65× 是和 extract_image 虚胖容器比的;对比真身 CCITT G4 裸流,PIL PNG 反而 ~1.7×。G4 打不过,无损档就是最优压缩档,压缩增强版只对灰度 JPEG 书有意义。
- 别和 PyMuPDF 低层 API 搏斗。试图手工拷贝 CCITT 裸流(
get_new_xref+update_object+update_stream(new=True)),update_stream无视我写的/Filter /CCITTFaxDecode强行改写成 FlateDecode,双重编码整页报废(渲染全白)。 --pages参数 0/1-based 混用导致整页错位。样张里"压缩后空白"的页其实根本没被处理——目检截图前先怀疑自己的索引约定。- CPU 跑满核会被用户投诉卡机。长任务的默认姿态应该是:有 GPU 走 GPU,没 GPU 限线程(
--threads)。 - 检测参数调敏感(阈值 0.3→0.25 等)在这两本书上零增益——v1/v2 逐页字符完全一致。缺字的真凶不是漏检,而是文本层字号过小导致选中高亮盖不住字形(见第三节 1)。先量化归因,再动手优化。
- 验收自动化是底线。图像流 MD5、渲染像素抽样、round-trip 字符比对三件套加上红框叠加目检图——上面每一次翻车都是它们抓住的,包括黑底白字那次的极性统计(dark%=91% 一眼定罪)。
五、实战数字¶
| 书 | 页数 | 源 | 交付 | round-trip |
|---|---|---|---|---|
| 理性、真理与历史(普特南) | 314 | 283DPI 灰度 JPEG, 61MB | 无损档 66MB + 压缩增强档 21.6MB(白底黑字,笔画更锐) | 313/313 |
| 哲学和自然之镜(罗蒂) | 521 | CCITT G4 二值, 19.9MB | 仅无损档 23.9MB(见坑 2) | 515/515 |
全书 OCR 置信度均值 ≈0.88。残余错误是形近字级别(肯定→背定),PP-OCRv3 移动端模型的上限;置信度报告可以定位低置信页重点校对。
六、局限与下一步¶
- 旋转 90° 的侧排页码检不出(cls 只支持 0°/180°),正文不受影响;
- 生僻字在
china-s子集字体下可能无字形,但复制出的文本仍正确(提取走 ToUnicode); - 若追求更小体积:JBIG2 编码可比 G4 再省 ~30%,但 Windows 下工具链(jbig2enc)不便,未做;PP-OCRv5 模型发布后换模型可再提精度。
管线完整代码在本仓库 ocr_pipeline/(PR #36),含双语 README 与七条坑的工程版记录。