OCR、扫描件与文字层
8.1 三种"PDF":你以为的 vs 实际的
8.1.1 从外观看不出区别
打开两份 PDF,肉眼看去完全一样——都是一页白纸上的黑字。但如果你尝试 Ctrl+A 全选文字:
- A 号文件:所有文字被选中,可以复制粘贴
- B 号文件:什么都选不中,或者光标根本不跟随文字
这两份 PDF 的内部结构完全不同:
| 类型 | 内部结构 | 可搜索 | 可复制 | 典型来源 |
|---|---|---|---|---|
| 原生文字 PDF | 内容流有文字绘图指令 | ✓ | ✓ | Word/LaTeX/Typst 导出 |
| 图片 PDF | 每页就是一张大图片 | ✗ | ✗ | 扫描仪直出 |
| OCR 增强 PDF | 图片底层 + 透明文字层 | ✓ | ✓ | OCR 处理后 |
8.1.2 图片 PDF 的内部长什么样
一份扫描件 PDF 的页面对象通常极其简单:
% 页面内容流
q
595.276 0 0 841.89 0 0 cm % 变换矩阵:把图片拉伸到整个页面
/Im1 Do % 画图片对象 Im1
Q
就这么三行。整页内容就是"画一张图"。没有文字操作符(BT/ET/Tj),没有字体对象,没有 CMap。
图片对象本身是一个巨大的 stream:
<< /Type /XObject
/Subtype /Image
/Width 2480
/Height 3508
/ColorSpace /DeviceGray
/BitsPerComponent 8
/Filter /DCTDecode % JPEG 压缩
/Length 185432
>>
stream
[185432 字节的 JPEG 数据]
endstream
2480×3508 像素,正好是 300 DPI 下 A4 纸的扫描分辨率。
8.2 OCR 的原理
8.2.1 什么是 OCR
OCR(Optical Character Recognition,光学字符识别)——让计算机"看"图片中的文字,识别出对应的字符。
OCR 的基本流程:
图像输入 → 预处理 → 文字检测 → 字符分割 → 字符识别 → 后处理
- 预处理:去噪、二值化、倾斜校正、去除边框/水印
- 文字检测:找到图片中哪些区域有文字(区分文字/图表/空白)
- 字符分割:把文字区域切分成单个字符(或词)
- 字符识别:对每个字符分类(这是"A"还是"B"还是"人"还是"大"?)
- 后处理:利用语言模型纠正识别错误("teh" → "the")
8.2.2 现代 OCR 引擎
| 引擎 | 类型 | 精度 | 速度 | 中文支持 |
|---|---|---|---|---|
| Tesseract 5 | 开源(Google 维护) | ★★★★☆ | ★★★☆☆ | ✓ |
| PaddleOCR | 开源(百度) | ★★★★★ | ★★★★☆ | ★★★★★ |
| Google Cloud Vision | 云服务 | ★★★★★ | ★★★★★ | ★★★★★ |
| Azure AI Vision | 云服务 | ★★★★★ | ★★★★☆ | ★★★★☆ |
| ABBYY FineReader | 商业软件 | ★★★★★ | ★★★☆☆ | ★★★★☆ |
现代 OCR 的核心模型是基于深度学习的——Tesseract 5 使用 LSTM 网络,PaddleOCR 使用 CRNN + Attention 架构。在清晰印刷体上,识别率可以达到 99%+。
但对于手写体、低质量扫描、复杂版面(多栏、表格、公式混排),精度仍然是一个挑战。
8.2.3 一段 OCR 的实际代码
用 Tesseract 处理一张图片:
import pytesseract
from PIL import Image
# 加载图片
img = Image.open("scanned_page.png")
# 识别文字
text = pytesseract.image_to_string(img, lang='chi_sim+eng')
print(text)
# 获取详细信息(每个字的位置和置信度)
data = pytesseract.image_to_data(img, lang='chi_sim+eng',
output_type=pytesseract.Output.DICT)
for i in range(len(data['text'])):
if data['conf'][i] > 60: # 置信度 > 60%
print(f"'{data['text'][i]}' at ({data['left'][i]}, {data['top'][i]}) "
f"conf={data['conf'][i]}%")
输出的关键信息:文字内容 + 位置坐标 + 置信度。这三样东西正好是生成 OCR PDF 文字层所需的。
8.3 OCR 增强 PDF:给图片加上"隐形文字"
8.3.1 文字层的原理
OCR 增强 PDF 的核心思想:在扫描图片上方叠加一层透明的文字。
页面结构:
┌─────────────────────────┐
│ 透明文字层(可搜索) │ ← 用户 Ctrl+F 搜索的目标
│ ┌───────────────────┐ │
│ │ 扫描图片(可见) │ │ ← 用户实际"看到"的内容
│ └───────────────────┘ │
└─────────────────────────┘
内容流的结构:
% 先画图片(底层)
q
595.276 0 0 841.89 0 0 cm
/Im1 Do
Q
% 再画透明文字(上层)
BT
3 Tr % Text Rendering Mode 3 = 不可见
/F1 12 Tf
72.5 750.3 Td
(This is the recognized text) Tj
ET
关键是 3 Tr——Text Rendering Mode 3 表示"既不填充也不描边",即文字完全不可见。但它在逻辑上存在于那个坐标位置,所以搜索和选择都能命中。
8.3.2 为什么 OCR 层的位置必须精确
如果 OCR 识别出 "Hello" 在图片中的位置是 (150, 200) 到 (250, 220),那么文字层中的 "Hello" 也必须精确放在同样的坐标范围。否则:
- 用户拖动选择文字时,高亮区域和实际文字不对齐
- 复制出来的文字顺序可能混乱
- 搜索定位后跳转到错误的位置
好的 OCR 工具会为每个词精确计算字号和字间距,让透明文字的边界和图片中的文字完全重合。
8.3.3 用 ocrmypdf 给扫描件添加文字层
# 安装
pip install ocrmypdf
# 基本用法:给扫描 PDF 加 OCR 文字层
ocrmypdf input_scan.pdf output_searchable.pdf
# 指定语言(中文简体 + 英文)
ocrmypdf -l chi_sim+eng input.pdf output.pdf
# 跳过已有文字的页面(混合文档)
ocrmypdf --skip-text input.pdf output.pdf
# 生成 PDF/A 存档格式
ocrmypdf --output-type pdfa input.pdf output.pdf
# 自动校正倾斜扫描
ocrmypdf --deskew --clean input.pdf output.pdf
ocrmypdf 内部调用 Tesseract 做 OCR,然后用 pikepdf/reportlab 在 PDF 中精确放置透明文字层。它是目前开源 OCR PDF 方案中最成熟的。
8.4 图片 PDF 的压缩策略
8.4.1 扫描件为什么那么大
一张 300 DPI 的 A4 灰度扫描件:
- 原始尺寸:2480 × 3508 × 8 bit = 8.3 MB
- 一本 200 页的书:1.66 GB
显然不能这样存储。不同的压缩方案:
| 压缩方式 | 压缩率(灰度文字页) | 适用场景 |
|---|---|---|
| JPEG (DCTDecode) | ~10:1 → 800KB/页 | 照片、彩色文档 |
| JPEG 2000 (JPXDecode) | ~15:1 → 550KB/页 | 高质量要求 |
| JBIG2 | ~50:1 → 160KB/页 | 黑白文字扫描件 |
| CCITT Group 4 | ~20:1 → 400KB/页 | 传真、简单黑白 |
| Deflate + PNG pred. | ~5:1 → 1.6MB/页 | 无损要求 |
8.4.2 JBIG2:极致压缩的代价
JBIG2 是专为黑白文档设计的压缩算法,其原理极其聪明:
- 符号字典:扫描页面中所有字符形状,把相似的归为一类。比如页面中出现了 50 个 "e",形状略有不同(扫描噪声),但 JBIG2 只存储一个"代表 e"的模板。
- 替换编码:把页面中每个字符替换为"字典中的第 N 个模板,放在坐标 (x,y)"。
- 残差编码:如果某个字符和模板差异较大,可以存储差异(有损 JBIG2 会跳过这步)。
有损 JBIG2 的压缩率惊人(50:1 甚至更高),但有一个著名的问题:它可能改变文字内容。
2013 年发现的案例:某 Xerox 复印机使用有损 JBIG2 压缩扫描件,把文档中的 "6" 替换成了 "8"(因为这两个字符的扫描图像被判定为"足够相似"而归入同一类)。在建筑蓝图的尺寸标注中,这种错误可能导致严重后果。
8.4.3 混合分层压缩(MRC)
高级扫描仪和 OCR 软件使用 **MRC(Mixed Raster Content)**分层压缩——和 DjVu 的思想相同:
前景层(文字/线条):JBIG2 或 CCITT,1-bit,高分辨率
背景层(图片/底色):JPEG,低分辨率
蒙版层:决定哪些像素来自前景、哪些来自背景
一页彩色杂志用 MRC 压缩后:文字锐利(来自高分辨率前景层),照片平滑(来自低分辨率背景层),总文件大小只有 JPEG 的 1/3。
8.5 扫描质量对 OCR 的影响
8.5.1 DPI 与识别率
| 扫描 DPI | 英文识别率 | 中文识别率 | 文件大小(灰度 A4) |
|---|---|---|---|
| 100 | ~85% | ~70% | ~120 KB (JPEG) |
| 150 | ~92% | ~82% | ~200 KB |
| 200 | ~96% | ~90% | ~350 KB |
| 300 | ~99% | ~96% | ~800 KB |
| 600 | ~99.5% | ~98% | ~3 MB |
300 DPI 是 OCR 的甜蜜点——再高收益递减,但文件急剧膨胀。
8.5.2 常见的扫描问题及影响
| 问题 | 对 OCR 的影响 | 解决方案 |
|---|---|---|
| 倾斜 | 行检测失败 | 自动 deskew(倾斜校正) |
| 噪点(椒盐噪声) | 误识别小点为标点 | 去噪滤波 |
| 透页(背面文字透过来) | 干扰前景文字识别 | 二值化阈值调整 |
| 曲面失真(书脊处弯曲) | 字符变形 | dewarping(去翘曲) |
| 低对比度(复印件的复印件) | 整体识别率下降 | 对比度增强 |
| 手写标注覆盖印刷文字 | 无法区分两层 | 较难处理 |
8.5.3 一个有趣的数据
Google Books 项目扫描了超过 4000 万本书。他们使用的扫描设备分辨率为 400-600 DPI,配合自研的 OCR 引擎。据估计,对于质量好的印刷书籍,他们的字符级准确率超过 99.9%——这意味着每 1000 个字还是可能有 1 个错误。
一本 10 万字的书,可能有 100 个 OCR 错误散布其中。对于人类读者来说几乎不影响理解(大脑会自动纠错),但对于精确性要求高的场景(法律文献、学术引用),这些错误可能是不可接受的。
8.6 判断一份 PDF 是否有文字层
8.6.1 快速判断方法
import fitz # PyMuPDF
doc = fitz.open("unknown.pdf")
page = doc[0]
# 提取文字
text = page.get_text()
if len(text.strip()) < 10:
print("这一页可能是纯图片(无文字层)")
else:
print(f"有文字层,提取到 {len(text)} 个字符")
# 检查页面中的图片
images = page.get_images()
print(f"页面包含 {len(images)} 张图片")
# 如果有 1 张占满页面的大图 + 几乎无文字 → 纯扫描件
# 如果有 1 张大图 + 有文字 → OCR 增强 PDF
# 如果无大图 + 有文字 → 原生文字 PDF
8.6.2 命令行工具
# 用 pdftotext 提取文字,看输出是否为空
pdftotext document.pdf - | head -20
# 用 pdfimages 列出所有嵌入图片
pdfimages -list document.pdf
# 输出示例(纯扫描件):
# page num type width height color comp bpc ...
# 1 0 image 2480 3508 gray 1 8 ...
如果每一页都只有一张和页面等大的图片,且 pdftotext 输出为空或只有垃圾字符——那就是纯扫描件。