一套能落地的文档解析方案,通常不是“一个模型解决所有问题”,而是版面切分 → 文字与表格识别 → 字段抽取 → 人工复核四段接力。这篇教程以 1.3B 量级的轻量开源 OCR 模型 OXR 为例(模型权重、仓库地址、支持的输入格式以官方 README 当前内容为准),把这条链路在一台单卡机器上跑通,并给出可复现的效果实测方法。
适用场景
适合需要把扫描件、PDF、截图里的中文内容转成结构化数据,但预算和数据合规都不允许调用公有云 OCR 的团队:发票、合同、报关单、体检报告、质检单这类“版式固定或半固定”的批量单据。单张消费级显卡甚至纯 CPU 就能跑,数据全程留在内网。
不适合的场景也说清楚:手写体占比高、严重倾斜或污损的档案,1.3B 级别的模型识别率会明显下降,建议先做小样本验证再决定是否上量。
环境与前置条件
| 项目 | 建议 | 说明 |
|---|---|---|
| 操作系统 | Ubuntu 20.04 / 22.04 LTS | Windows 建议走 WSL2,macOS 可用 MPS 但速度一般 |
| Python | 3.10 或 3.11 | 版本过低会导致部分依赖装不上 |
| GPU | 8GB 显存起步,16GB 更宽裕 | 1.3B 模型 fp16 权重约 2.6GB,加上图像编码器和激活值,6–8GB 可跑单页;显存越大越能堆 batch |
| CPU 兜底 | 纯 CPU 可运行 | 单页耗时会长很多,适合离线补数据,不适合实时 |
| 内存 | 16GB 以上 | PDF 转图和多进程时会吃内存 |
| 磁盘 | 20GB 以上 | 模型权重 + 样本 + 输出归档 |
其余依赖:系统需要 libgl1、libglib2.0-0(OpenCV 用到)、poppler-utils(PDF 处理)、中文字体(生成预览图用)。
分步骤部署
步骤 1:确认显卡与驱动
```bash
nvidia-smi
```
输出里能看到显卡型号、显存总量、Driver Version 和右上角的 CUDA Version 就算通过。如果命令不存在,先装官方驱动;CUDA Version 低于 PyTorch 官方安装页要求时,按官方页面给出的对应版本命令安装,不要凭记忆抄旧命令。
步骤 2:装系统依赖
```bash
sudo apt-get update
sudo apt-get install -y python3-venv python3-pip git \
libgl1 libglib2.0-0 poppler-utils fonts-noto-cjk
```
这一步装的是“Python 包管不到的东西”,尤其是 libGL.so.1,缺了会在导入 OpenCV 时直接报错。
步骤 3:建独立虚拟环境
```bash
python3 -m venv ~/oxr-env
source ~/oxr-env/bin/activate
python -V # 确认 3.10 / 3.11
pip install -U pip
```
之后所有命令都在这个环境里执行。不要装进系统 Python,否则后面升级依赖会互相打架。
步骤 4:安装推理框架
先按 PyTorch 官方安装页当前给出的命令装 PyTorch(选择与你驱动匹配的 CUDA 版本,示例里的 cu121 只是占位,按官网选项替换):
```bash
pip install torch torchvision --index-url https://download.pytorch.org/whl/cu121
python -c "import torch; print(torch.__version__, torch.cuda.is_available())"
```
第二条命令输出 True 表示 GPU 可用;输出 False 说明装成了 CPU 版本,回去换安装命令重装。
接着装其余依赖:
```bash
pip install -U transformers accelerate pillow opencv-python-headless \
pymupdf pandas tqdm huggingface_hub
```
这一步不锁版本,先跑通;后面在“后续维护”里用 pip freeze 把版本钉死。
步骤 5:拉取模型权重
```bash
网络受限时先设镜像;镜像可用性以镜像站当前状态为准
export HF_ENDPOINT=https://hf-mirror.com
hf download <官方仓库ID> --local-dir ./models/oxr-1.3b
```
<官方仓库ID> 替换成官方 README 里给出的仓库名。成功标准:./models/oxr-1.3b 目录下出现权重文件(.safetensors 等)、config.json、preprocessor_config.json,以及模型自带的自定义代码文件。目录大小与官方页面标注的参数量基本吻合即可。
步骤 6:单图跑通最小推理
先写一个只做“图 → Markdown”的脚本,确认模型本身能出字,再谈流水线。
```python
infer_one.py
import torch
from PIL import Image, ImageOps
from transformers import AutoProcessor, AutoModelForVision2Seq
MODEL_DIR = "./models/oxr-1.3b"
PROMPT = ("请把这张图片里的全部文字按自然阅读顺序转成 Markdown;"
"表格用 HTML 表格输出并保留表头;公式用 LaTeX。不要输出解释性文字。")
processor = AutoProcessor.from_pretrained(MODEL_DIR, trust_remote_code=True)
model = AutoModelForVision2Seq.from_pretrained(
MODEL_DIR, torch_dtype=torch.bfloat16, device_map="auto",
trust_remote_code=True,
).eval()
def ocr(image_path: str) -> str:
img = ImageOps.exif_transpose(Image.open(image_path)).convert("RGB")
messages = [{"role": "user",
"content": [{"type": "image"}, {"type": "text", "text": PROMPT}]}]
text = processor.apply_chat_template(messages, add_generation_prompt=True)
inputs = processor(text=text, images=img, return_tensors="pt").to(model.device)
with torch.inference_mode():
out = model.generate(**inputs, max_new_tokens=2048, do_sample=False)
new_tokens = out[:, inputs["input_ids"].shape[1]:]
return processor.batch_decode(new_tokens, skip_special_tokens=True)[0]
if __name__ == "__main__":
print(ocr("samples/demo.png"))
```
说明两点:这里用的是多数 OCR-VLM 通用的 AutoProcessor + generate 写法;如果官方仓库给的是 AutoModel 加自定义 chat() 接口,按 README 替换加载与调用两行即可,其余流程不变。ImageOps.exif_transpose 不能省——手机拍的图带旋转标记,不处理会把横图当竖图识别。
成功标准:终端打印出与原图一致的中文段落,表格部分出现 <table> 标签。
步骤 7:PDF 转图 + 版面切分
整页直接送模型,在版式复杂时容易串行、漏行。做法是先用投影法把页面切成“文字块”,逐块识别,再按纵坐标拼回去。
```python
prep.py
import cv2, fitz, numpy as np
def pdf_to_images(pdf_path: str, out_dir: str, dpi: int = 200):
doc = fitz.open(pdf_path)
paths = []
for i, page in enumerate(doc):
pix = page.get_pixmap(dpi=dpi)
p = f"{out_dir}/page_{i:04d}.png"
pix.save(p)
paths.append(p)
return paths
def split_blocks(img_path: str, min_gap: int = 20, min_h: int = 30):
"""按水平投影的空白行切块,返回 [(y1, y2), ...]"""
img = cv2.imread(img_path, cv2.IMREAD_GRAYSCALE)
bw = cv2.threshold(img, 0, 255,
cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU)[1]
rows = (bw > 0).sum(axis=1)
blocks, start, gap = [], None, 0
for y, cnt in enumerate(rows):
if cnt > 0:
start = y if start is None else start
gap = 0
elif start is not None:
gap += 1
if gap >= min_gap:
if y - gap - start >= min_h:
blocks.append((start, y - gap))
start, gap = None, 0
if start is not None and len(rows) - start >= min_h:
blocks.append((start, len(rows)))
return blocks
```
切完后按 y1 排序、逐块裁剪送模型、按顺序拼接,跨块段落一般能自然衔接。遇到双栏排版、图文混排的合同,投影法会把左右栏粘在一起,这时换用版面检测模型(如 PP-Structure 一类方案,版本以官方文档为准)输出区块坐标,再走同样的“裁块 → 识别 → 拼接”。
步骤 8:表格还原与结构校验
让模型直接输出 HTML 表格,比让它输出 CSV 稳,因为合并单元格、表头这些信息 HTML 能表达。
```python
import io, pandas as pd
def parse_tables(html: str):
try:
return pd.read_html(io.StringIO(html))
except ValueError:
return []
def check_table(df: pd.DataFrame) -> bool:
"""列数一致、无整行为空,才算结构可信"""
return df.shape[1] > 1 and df.dropna(how="all").shape[0] > 0
```
校验不过的表格不硬猜,直接打上 need_review=True 丢进复核队列——这是整条流水线可信度的关键。
步骤 9:字段抽取(规则优先,模型兜底)
固定字段先用正则,命中率高、零成本;正则抽不到的再让模型按 JSON 输出。
```python
import re
FIELDS = {
"doc_no": r"(?:发票号码|单据编号|合同编号)[::\s]*([A-Za-z0-9\-]{6,})",
"date": r"(?:开票日期|签订日期|日期)[::\s]*(\d{4}[-年/]\d{1,2}[-月/]\d{1,2})",
"amount": r"(?:价税合计|合计金额|总金额)[::\s]*[¥¥]?\s*([\d,]+\.\d{2})",
}
def rule_extract(text: str) -> dict:
out = {}
for k, pat in FIELDS.items():
m = re.search(pat, text)
out[k] = m.group(1).replace(",", "") if m else None
return out
```
模型兜底的提示词示例:“从下面的文本中抽取 doc_no、date、amount 三个字段,输出严格 JSON,找不到的字段填 null,不要编造。”拿到结果后统一做类型校验(金额转 float、日期转标准格式),校验失败的进复核队列。
步骤 10:串成批处理脚本
```python
pipeline.py
import json, time, pathlib
from prep import pdf_to_images, split_blocks
from infer_one import ocr
from PIL import Image
def run(indir: str, outdir: str):
out = pathlib.Path(outdir); out.mkdir(parents=True, exist_ok=True)
review = open(out / "review.jsonl", "a", encoding="utf-8")
for pdf in sorted(pathlib.Path(indir).glob("*.pdf")):
t0 = time.time()
pages = pdf_to_images(str(pdf), str(out / pdf.stem))
texts = []
for p in pages:
img = Image.open(p)
for (y1, y2) in split_blocks(p):
crop = img.crop((0, y1, img.width, y2))
cp = f"{p}.{y1}.png"; crop.save(cp)
texts.append(ocr(cp))
rec = {"file": pdf.name, "text": "\n".join(texts),
"cost_s": round(time.time() - t0, 2)}
(out / f"{pdf.stem}.json").write_text(
json.dumps(rec, ensure_ascii=False, indent=2), encoding="utf-8")
if rec["cost_s"] > 60 or not rec["text"].strip():
review.write(json.dumps({"file": pdf.name, "reason": "slow_or_empty"},
ensure_ascii=False) + "\n")
review.close()
```
步骤 11:人工复核
复核不需要复杂系统,一个 JSONL + 表格软件就够起步:review.jsonl 里每行一条待核记录,字段包括文件名、页码、可疑原因、原始截图路径。核完把结论回写同一行("status": "ok" / "fixed"),这批数据就是后续调提示词、调切块参数的回归集。
验证部署是否成功
1. 环境层验证
```bash
python -c "import torch; print(torch.cuda.is_available(), torch.cuda.get_device_name(0))"
```
预期输出 True 加显卡型号。
2. 单图验证
```bash
python infer_one.py
```
预期:打印出中文文本;若你的样例图里含表格,应能看到 <table>。输出为空或全是重复字符,说明提示词或解码参数有问题,先调 max_new_tokens,再查图片是否全白/全黑。
3. 小批量贯通验证
```bash
mkdir -p out && python -c "from pipeline import run; run('samples', 'out')"
ls out/*.json | wc -l
```
预期:JSON 数量与 samples/ 里 PDF 数量一致;out/review.jsonl 行数应远小于总数(复核率一般控制在 10%–20% 以内算健康)。
4. 效果实测(自己跑,别抄别人的数字)
取 20–30 页覆盖你真实业务的样本,人工校对一份“标准答案”,然后算字符错误率(CER):
```python
def edit_distance(a, b):
prev = list(range(len(b) + 1))
for i, ca in enumerate(a, 1):
cur = [i]
for j, cb in enumerate(b, 1):
cur.append(min(prev[j] + 1, cur[j-1] + 1, prev[j-1] + (ca != cb)))
prev = cur
return prev[-1]
def cer(pred: str, gold: str) -> float:
return edit_distance(pred, gold) / max(len(gold), 1)
```
把结果填进下面这张表——不同版式差异极大,别人的数字对你没有参考价值:
| 文档类型 | 样本页数 | CER | 表格结构与原文一致 | 字段一次抽对率 | 单页耗时 |
|---|---|---|---|---|---|
| 增值税发票 | |||||
| 采购合同 | |||||
| 检验报告 |
判断标准:CER 低于 2% 可直接入库,2%–5% 抽检复核,超过 5% 的文档类型先做图像增强(去噪、纠偏、提高 DPI)再重测。
常见报错与解决
报错 1:torch.cuda.OutOfMemoryError: CUDA out of memory
原因:显存不够,通常是单页分辨率过高或 max_new_tokens 设太大。
解决:
```bash
export PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True
```
同时把 PDF 转图 DPI 从 300 降到 200,整页切成小块送模型,max_new_tokens 按实际文本长度下调。
报错 2:OSError: We couldn't connect to 'https://huggingface.co' 或下载卡住
原因:网络访问受限。
解决:
```bash
export HF_ENDPOINT=https://hf-mirror.com
export HF_HUB_ENABLE_HF_TRANSFER=1
hf download <官方仓库ID> --local-dir ./models/oxr-1.3b
```
仍失败时在能联网的机器上下载后整目录拷贝,用 --local-dir 指向本地路径加载。
报错 3:ImportError: libGL.so.1: cannot open shared object file
原因:OpenCV 依赖的系统库缺失,常见于精简版镜像。
解决:
```bash
sudo apt-get install -y libgl1 libglib2.0-0
```
或改装 opencv-python-headless。
报错 4:加载模型时报 unexpected key(s) in state_dict 或 trust_remote_code 相关异常
原因:transformers 版本与模型自带的自定义代码不匹配。
解决:按官方仓库 requirements.txt 安装对应版本,不要盲目 pip install -U:
```bash
pip install "transformers==<仓库要求的版本>"
```
报错 5:识别结果中文乱码或出现大量重复片段
原因:图片带 EXIF 旋转、或者用了采样解码导致重复。
解决:读取时加 ImageOps.exif_transpose,生成参数用 do_sample=False,并检查 processor 与 model 是否来自同一目录。
后续维护
备份:三样东西必须备份——模型目录(记下官方仓库的 commit 或权重 sha256)、代码与依赖清单、历史输出 JSONL。输出文件按 年/月/日 分目录归档,保留原始截图,复核结论回写后不可覆盖原记录。
升级:上线前先 pip freeze > requirements.txt 锁死版本。模型或依赖升级时,用固定回归集(那 20–30 页人工校对样本)跑一遍,对比 CER、字段准确率、单页耗时三项,指标下降就回滚。升级永远走“新目录并行部署 → 回归集对比 → 切流量”,不要原地覆盖。
日志与监控:每页记录页码、耗时、输出 token 数、是否进复核队列、异常类型,写进结构化日志。日常盯三个指标:单页耗时 P95 是否漂移、复核率是否上升、GPU 显存峰值是否打满。磁盘水位单独告警——图片中间产物增长很快,建议处理后按策略清理裁剪块,只保留原图和 JSON。
最后提醒一句:投入生产前确认模型的许可证是否允许你的使用方式(尤其是商用和二次分发),以官方仓库的 LICENSE 为准。
