跳到主要内容

PDF识别提取专家

收藏0

PDF/图片/扫描件 OCR 提取文字转 Markdown。支持 PP-OCRv6 三档模型,适合合同、发票、中文扫描件等。灵栈搬运自 SkillHub pdf-ocr-md v3.4.0。

让 AI 安装

Minsaroun最新版本 0.1.0更新于 2026-11-04下载 0 次

scripts/pdf_ocr_v6.py

14.8 KB
1#!/usr/bin/env python3
2"""
3pdf_ocr_v6.py — PP-OCRv6 三档模型 OCR(ONNX Runtime)
4
5使用 PP-OCRv6 模型家族进行本地 OCR,支持 Tiny / Small / Medium 三档选择。
6
7用法:
8 # Tiny 模型(极速,1.5MB)
9 python scripts/pdf_ocr_v6.py input.jpg --tier tiny
10
11 # Small 模型(均衡,7.7MB)
12 python scripts/pdf_ocr_v6.py input.jpg --tier small -o output.txt
13
14 # Medium 模型(高精度,34.5MB)
15 python scripts/pdf_ocr_v6.py input.jpg --tier medium -o output.txt
16
17 # PDF 批量处理
18 python scripts/pdf_ocr_v6.py input.pdf --tier medium -o ./output_dir
19"""
20
21import argparse
22import json
23import os
24import sys
25from pathlib import Path
26
27try:
28 import cv2
29 import numpy as np
30 import onnxruntime
31except ImportError:
32 print("请先安装依赖: pip install onnxruntime opencv-python-headless numpy Pillow pdf2image")
33 sys.exit(1)
34
35
36# ── 模型路径配置 ──────────────────────────────────────────────
37
38MODELS_BASE = os.path.expanduser(r"~\.workbuddy\.venvs\ppocrv6_models")
39
40MODEL_TIERS = {
41 "tiny": {
42 "dir": "ppocrv6_onnx",
43 "size": "1.5 MB",
44 "desc": "极速,可浏览器端运行",
45 },
46 "small": {
47 "dir": "ppocrv6_small_onnx",
48 "size": "7.7 MB",
49 "desc": "性能均衡",
50 },
51 "medium": {
52 "dir": "ppocrv6_medium_onnx",
53 "size": "34.5 MB",
54 "desc": "精度最高",
55 },
56}
57
58
59# ── OCR 引擎 ──────────────────────────────────────────────────
60
61class PaddleOCREngine:
62 """PP-OCRv6 ONNX Runtime 推理引擎"""
63
64 def __init__(self, tier: str = "tiny", device: str = "cpu"):
65 if tier not in MODEL_TIERS:
66 raise ValueError(f"不支持的模型档位: {tier},可选: {list(MODEL_TIERS.keys())}")
67
68 model_dir = os.path.join(MODELS_BASE, MODEL_TIERS[tier]["dir"])
69 det_path = os.path.join(model_dir, "det", "inference.onnx")
70 rec_path = os.path.join(model_dir, "rec", "inference.onnx")
71
72 if not os.path.exists(det_path) or not os.path.exists(rec_path):
73 raise FileNotFoundError(
74 f"模型文件未找到: {model_dir}\n"
75 f"请先下载模型: cd {MODELS_BASE} && 从 https://github.com/andyhuo520/ppocrv6-studio/releases 下载 {tier}.tar.gz"
76 )
77
78 # 选择推理后端
79 if device == "gpu":
80 try:
81 import onnxruntime as _ort
82 if "DmlExecutionProvider" not in _ort.get_available_providers():
83 print("⚠️ DirectML 不可用,回退到 CPU。请安装: pip install onnxruntime-directml")
84 device = "cpu"
85 except ImportError:
86 print("⚠️ onnxruntime 未安装,无法检查 DirectML")
87 device = "cpu"
88
89 if device == "gpu":
90 providers = ["DmlExecutionProvider", "CPUExecutionProvider"]
91 print(f" 🔧 后端: DirectML (Intel Arc GPU)")
92 else:
93 providers = ["CPUExecutionProvider"]
94 print(f" 🔧 后端: CPU")
95
96 self.det_session = onnxruntime.InferenceSession(det_path, providers=providers)
97 self.rec_session = onnxruntime.InferenceSession(rec_path, providers=providers)
98
99 # 获取输入输出名称
100 self.det_input = self.det_session.get_inputs()[0].name
101 self.rec_input = self.rec_session.get_inputs()[0].name
102
103 # 加载字符字典 (PP-OCRv6 的 char_dict.json 是 list, 不是 dict)
104 # Tiny 有独立的 char_dict.json; Small/Medium 需从 inference.yml 解析
105 char_dict_path = os.path.join(model_dir, "rec", "char_dict.json")
106 yml_path = os.path.join(model_dir, "rec", "inference.yml")
107
108 if os.path.exists(char_dict_path):
109 with open(char_dict_path, "r", encoding="utf-8") as f:
110 self.char_dict = json.load(f)
111 if isinstance(self.char_dict, dict):
112 self.char_dict = list(self.char_dict.values())
113 elif os.path.exists(yml_path):
114 # 从 inference.yml 解析 character_dict 列表
115 self.char_dict = self._parse_char_dict_from_yml(yml_path)
116 else:
117 self.char_dict = None
118
119 if self.char_dict:
120 print(f" 📖 字符字典: {len(self.char_dict)} 个字符")
121
122 self.tier = tier
123 self.device = device
124
125 # ── det 预处理参数(来自 inference.yml)──
126 DET_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32)
127 DET_STD = np.array([0.229, 0.224, 0.225], dtype=np.float32)
128 DET_THRESH = 0.2 # binarization threshold (from config)
129 DET_BOX_THRESH = 0.4 # box threshold (from config)
130 DET_MIN_AREA = 20
131 DET_TARGET_SIZE = 640
132
133 # ── rec 参数(来自 inference.yml)──
134 REC_H, REC_W = 48, 320
135
136 @staticmethod
137 def _parse_char_dict_from_yml(yml_path: str) -> list:
138 """从 inference.yml 的 PostProcess.character_dict 列表中提取字符"""
139 import re
140 with open(yml_path, "r", encoding="utf-8") as f:
141 lines = f.readlines()
142
143 # 找到 character_dict: 行,然后收集后续的 " - " 列表项
144 chars = []
145 in_dict = False
146 for line in lines:
147 if "character_dict:" in line:
148 in_dict = True
149 continue
150 if in_dict:
151 # 列表项格式: " - 'x'" 或 " - x"
152 m = re.match(r'\s*-\s+(.*)', line)
153 if m:
154 val = m.group(1).strip()
155 # 去除引号
156 if (val.startswith("'") and val.endswith("'")) or \
157 (val.startswith('"') and val.endswith('"')):
158 val = val[1:-1]
159 chars.append(val)
160 elif line.strip() and not line.strip().startswith("#"):
161 # 遇到非列表项且非注释,说明 character_dict 段结束
162 if not line.startswith(" " * 4): # 缩进减少,段结束
163 break
164 return chars
165
166 def detect(self, img: np.ndarray) -> list:
167 """文本检测: 返回文本框列表 [[x1,y1,x2,y2,...], ...]"""
168 h, w = img.shape[:2]
169 # 保持长宽比 resize 到 640
170 scale = self.DET_TARGET_SIZE / max(h, w)
171 new_h, new_w = int(h * scale), int(w * scale)
172 resized = cv2.resize(img, (new_w, new_h))
173 # pad 到 640x640
174 canvas = np.zeros((self.DET_TARGET_SIZE, self.DET_TARGET_SIZE, 3), dtype=np.uint8)
175 canvas[:new_h, :new_w] = resized
176
177 # 归一化: (img/255 - mean) / std, 然后 HWC→CHW
178 inp = canvas.astype(np.float32) / 255.0
179 inp = (inp - self.DET_MEAN) / self.DET_STD
180 inp = inp.transpose(2, 0, 1) # HWC → CHW
181 inp = np.expand_dims(inp, axis=0)
182
183 out = self.det_session.run(None, {self.det_input: inp})[0]
184 # 输出 shape: (1, 1, 640, 640) → squeeze 到 2D
185 prob_map = out.squeeze() # (640, 640)
186 return self._db_postprocess(prob_map, (w, h), (new_h, new_w))
187
188 def _db_postprocess(self, prob_map: np.ndarray, orig_size: tuple, resized_size: tuple,
189 threshold: float = None) -> list:
190 """DB 后处理: prob_map → 文本框"""
191 if threshold is None:
192 threshold = self.DET_THRESH
193
194 h_prob, w_prob = prob_map.shape
195 bin_map = (prob_map > threshold).astype(np.uint8) * 255
196 contours, _ = cv2.findContours(bin_map, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
197
198 ow, oh = orig_size
199 new_h, new_w = resized_size
200 boxes = []
201 for cnt in contours:
202 area = cv2.contourArea(cnt)
203 if area < self.DET_MIN_AREA:
204 continue
205 # 计算最小外接矩形
206 rect = cv2.minAreaRect(cnt)
207 pts = cv2.boxPoints(rect)
208 # 只取 prob_map 有效区域内的框(排除 padding 区产生的假框)
209 if pts[:, 0].max() > new_w + 5 or pts[:, 1].max() > new_h + 5:
210 continue
211 # 缩放回原图尺寸
212 pts[:, 0] = pts[:, 0] / new_w * ow
213 pts[:, 1] = pts[:, 1] / new_h * oh
214 boxes.append(pts.astype(np.int32).tolist())
215 return boxes
216
217 def recognize(self, img: np.ndarray, box: list) -> tuple[str, float]:
218 """文本框识别: 返回 (文本, 置信度)"""
219 pts = np.array(box, dtype=np.float32)
220 x, y, bw, bh = cv2.boundingRect(pts)
221 pad = max(3, int(bh * 0.1))
222 x1 = max(0, x - pad)
223 y1 = max(0, y - pad)
224 x2 = min(img.shape[1], x + bw + pad)
225 y2 = min(img.shape[0], y + bh + pad)
226 crop = img[y1:y2, x1:x2]
227
228 if crop.size == 0 or crop.shape[0] < 5 or crop.shape[1] < 5:
229 return "", 0.0
230
231 # 保持宽高比 resize 到 (48, 320)
232 h, w = crop.shape[:2]
233 ratio = w / h
234 target_w = min(int(self.REC_H * ratio), self.REC_W)
235 target_w = max(target_w, 1)
236 resized = cv2.resize(crop, (target_w, self.REC_H))
237 # pad 宽度到 320
238 canvas = np.zeros((self.REC_H, self.REC_W, 3), dtype=np.uint8)
239 canvas[:, :target_w] = resized
240
241 # 归一化到 [0, 1], HWC → CHW
242 inp = canvas.astype(np.float32) / 255.0
243 inp = inp.transpose(2, 0, 1)
244 inp = np.expand_dims(inp, axis=0)
245
246 outs = self.rec_session.run(None, {self.rec_input: inp})
247 probs = outs[0][0] # shape: (T, num_classes)
248 pred_idx = probs.argmax(axis=-1)
249
250 # CTC 解码: char_dict 是 list, index 0 = blank
251 text = ""
252 conf_sum = 0.0
253 count = 0
254 last_idx = 0 # 0 = blank
255
256 for t in range(len(pred_idx)):
257 idx = int(pred_idx[t])
258 if idx != 0 and idx != last_idx:
259 # char_dict[idx-1] 对应模型输出 idx (idx 0 = blank)
260 if self.char_dict and idx - 1 < len(self.char_dict):
261 char = self.char_dict[idx - 1]
262 text += char
263 conf_sum += float(probs[t, idx])
264 count += 1
265 last_idx = idx
266
267 conf = conf_sum / max(count, 1)
268 return text.strip(), conf
269
270 def ocr_image(self, img: np.ndarray, min_conf: float = 0.5) -> list[dict]:
271 """完整 OCR 流程: 检测 → 识别"""
272 boxes = self.detect(img)
273 results = []
274 for box in boxes:
275 text, conf = self.recognize(img, box)
276 if text and conf >= min_conf:
277 results.append({
278 "text": text,
279 "confidence": round(float(conf), 4),
280 "box": box,
281 })
282 return results
283
284
285# ── CLI ───────────────────────────────────────────────────────
286
287def main():
288 parser = argparse.ArgumentParser(
289 description="PP-OCRv6 三档模型 OCR — ONNX Runtime 本地推理",
290 formatter_class=argparse.RawDescriptionHelpFormatter,
291 epilog="""
292模型档位:
293 tiny (1.5 MB) 极速,可浏览器端运行
294 small (7.7 MB) 性能均衡
295 medium (34.5 MB) 精度最高
296
297示例:
298 %(prog)s input.jpg --tier medium
299 %(prog)s input.jpg --tier tiny -o output.txt
300 %(prog)s input.pdf --tier medium -o ./output
301 %(prog)s input.jpg --tier small --device gpu
302 """,
303 )
304 parser.add_argument("input", help="输入文件 (图片或 PDF)")
305 parser.add_argument("--tier", "-t", choices=["tiny", "small", "medium"],
306 default="tiny", help="模型档位 (默认: tiny)")
307 parser.add_argument("--device", "-d", choices=["cpu", "gpu"],
308 default="gpu", help="推理后端 (默认: gpu (DirectML),cpu 用 CPU)")
309 parser.add_argument("--output", "-o", help="输出文件或目录")
310 parser.add_argument("--min-conf", type=float, default=0.5,
311 help="最低置信度阈值 (默认: 0.5)")
312
313 args = parser.parse_args()
314
315 input_path = args.input
316 if not os.path.exists(input_path):
317 print(f"❌ 文件不存在: {input_path}")
318 sys.exit(1)
319
320 # 加载引擎
321 print(f"🔧 加载 PP-OCRv6 {args.tier} 模型...")
322 try:
323 engine = PaddleOCREngine(tier=args.tier, device=args.device)
324 except FileNotFoundError as e:
325 print(f"❌ {e}")
326 sys.exit(1)
327 print(f"✅ 模型已加载")
328
329 # PDF 或图片
330 ext = input_path.lower()
331 if ext.endswith(".pdf"):
332 try:
333 from pdf2image import convert_from_path
334 except ImportError:
335 print("❌ PDF 处理需要 pdf2image: pip install pdf2image")
336 sys.exit(1)
337 print(f"📄 转换 PDF → 图片...")
338 images = convert_from_path(input_path, dpi=300)
339 imgs = [cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR) for img in images]
340 else:
341 img = cv2.imread(input_path)
342 if img is None:
343 print(f"❌ 无法读取图片: {input_path}")
344 sys.exit(1)
345 imgs = [img]
346
347 # OCR
348 all_results = []
349 for i, img in enumerate(imgs):
350 print(f" OCR 第 {i+1}/{len(imgs)} 页...")
351 results = engine.ocr_image(img, min_conf=args.min_conf)
352 all_results.append({"page": i + 1, "results": results})
353 print(f" 识别 {len(results)} 个文本块")
354
355 # 输出
356 basename = os.path.splitext(os.path.basename(input_path))[0]
357
358 if args.output:
359 out_path = args.output
360 if os.path.isdir(out_path) or len(imgs) > 1:
361 os.makedirs(out_path, exist_ok=True)
362 json_path = os.path.join(out_path, f"{basename}.json")
363 text_path = os.path.join(out_path, f"{basename}.txt")
364 else:
365 json_path = out_path.replace(".txt", ".json") if out_path.endswith(".txt") else out_path + ".json"
366 text_path = out_path
367 else:
368 json_path = f"{basename}_ocr.json"
369 text_path = f"{basename}_ocr.txt"
370
371 # 保存 JSON
372 output_data = {
373 "engine": f"PP-OCRv6 {args.tier}",
374 "model_size": MODEL_TIERS[args.tier]["size"],
375 "pages": all_results,
376 }
377 with open(json_path, "w", encoding="utf-8") as f:
378 json.dump(output_data, f, ensure_ascii=False, indent=2)
379 print(f"\n ✅ JSON: {json_path}")
380
381 # 保存文本
382 lines = []
383 for page in all_results:
384 lines.append(f"--- 第 {page['page']} 页 ---")
385 for r in page["results"]:
386 lines.append(r["text"])
387 lines.append("")
388 Path(text_path).write_text("\n".join(lines), encoding="utf-8")
389 print(f" ✅ 文本: {text_path}")
390
391 # 汇总统计
392 total_blocks = sum(len(p["results"]) for p in all_results)
393 avg_conf = sum(r["confidence"] for p in all_results for r in p["results"]) / max(total_blocks, 1)
394 print(f"\n📊 统计: {total_blocks} 个文本块, 平均置信度 {avg_conf:.2f}")
395
396
397if __name__ == "__main__":
398 main()

文件内容是平台检查时读出的文本,只供查看。平台不会运行包里的任何脚本或程序。