multi-ocr-py 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- multi_ocr/__init__.py +25 -0
- multi_ocr/batch.py +139 -0
- multi_ocr/cli.py +206 -0
- multi_ocr/engines/__init__.py +49 -0
- multi_ocr/engines/base.py +94 -0
- multi_ocr/engines/dashscope.py +85 -0
- multi_ocr/engines/liteparse.py +60 -0
- multi_ocr/engines/ollama.py +61 -0
- multi_ocr/engines/siliconflow.py +80 -0
- multi_ocr/pdf_utils.py +146 -0
- multi_ocr/single.py +91 -0
- multi_ocr_py-0.1.0.dist-info/METADATA +129 -0
- multi_ocr_py-0.1.0.dist-info/RECORD +16 -0
- multi_ocr_py-0.1.0.dist-info/WHEEL +4 -0
- multi_ocr_py-0.1.0.dist-info/entry_points.txt +2 -0
- multi_ocr_py-0.1.0.dist-info/licenses/LICENSE +21 -0
multi_ocr/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Multi-OCR: 多引擎 OCR 工具包。
|
|
2
|
+
|
|
3
|
+
同时支持 CLI 工具和 SDK 依赖包两种使用方式。
|
|
4
|
+
|
|
5
|
+
CLI 使用:
|
|
6
|
+
multi-ocr <path> [--model MODEL] [--pages PAGES]
|
|
7
|
+
|
|
8
|
+
SDK 使用:
|
|
9
|
+
from multi_ocr import get_engine, ocr_file, OCREngine
|
|
10
|
+
|
|
11
|
+
engine = get_engine("siliconflow", "deepseek-ai/DeepSeek-OCR", api_key="...")
|
|
12
|
+
result = ocr_file(Path("mydoc.pdf"), engine)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from multi_ocr.engines import get_engine
|
|
16
|
+
from multi_ocr.engines.base import OCREngine
|
|
17
|
+
from multi_ocr.single import ocr_file
|
|
18
|
+
from multi_ocr.batch import ocr_directory
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"get_engine",
|
|
22
|
+
"OCREngine",
|
|
23
|
+
"ocr_file",
|
|
24
|
+
"ocr_directory",
|
|
25
|
+
]
|
multi_ocr/batch.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""批量处理模块:对目录下的图片或 PDF 进行批量 OCR。
|
|
2
|
+
|
|
3
|
+
目录下只能有一种类型的文件(全图片或全 PDF),混合目录直接报错。
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import sys
|
|
7
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from tqdm import tqdm
|
|
11
|
+
|
|
12
|
+
from multi_ocr.engines.base import OCREngine
|
|
13
|
+
from multi_ocr.single import ocr_file
|
|
14
|
+
|
|
15
|
+
# 支持的图片格式
|
|
16
|
+
_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg"}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _collect_files(input_dir: Path) -> tuple[list[Path], list[Path]]:
|
|
20
|
+
"""扫描目录,收集图片和 PDF 文件,均按文件名排序。
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
input_dir: 输入目录路径。
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
(图片文件路径列表, PDF 文件路径列表),均已按文件名排序。
|
|
27
|
+
"""
|
|
28
|
+
images: list[Path] = []
|
|
29
|
+
pdfs: list[Path] = []
|
|
30
|
+
|
|
31
|
+
for entry in sorted(input_dir.iterdir(), key=lambda p: p.name):
|
|
32
|
+
if not entry.is_file():
|
|
33
|
+
continue
|
|
34
|
+
if entry.suffix.lower() in _IMAGE_EXTENSIONS:
|
|
35
|
+
images.append(entry)
|
|
36
|
+
elif entry.suffix.lower() == ".pdf":
|
|
37
|
+
pdfs.append(entry)
|
|
38
|
+
|
|
39
|
+
return images, pdfs
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _batch_images(
|
|
43
|
+
input_dir: Path, images: list[Path], engine: OCREngine, concurrency: int = 1
|
|
44
|
+
) -> None:
|
|
45
|
+
"""图片批量处理:逐张识别,合并结果写入 input_dir.md。
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
input_dir: 输入目录路径。
|
|
49
|
+
images: 已排序的图片文件路径列表。
|
|
50
|
+
engine: OCR 引擎实例。
|
|
51
|
+
concurrency: 并发数,默认 1(串行)。
|
|
52
|
+
"""
|
|
53
|
+
if concurrency > 1:
|
|
54
|
+
results: list[str | None] = [None] * len(images)
|
|
55
|
+
with ThreadPoolExecutor(max_workers=concurrency) as executor:
|
|
56
|
+
future_to_idx = {
|
|
57
|
+
executor.submit(engine.parse_image, img_path): idx
|
|
58
|
+
for idx, img_path in enumerate(images)
|
|
59
|
+
}
|
|
60
|
+
for future in tqdm(
|
|
61
|
+
as_completed(future_to_idx),
|
|
62
|
+
total=len(images),
|
|
63
|
+
desc=f"处理 {input_dir.name} 目录",
|
|
64
|
+
file=sys.stderr,
|
|
65
|
+
):
|
|
66
|
+
idx = future_to_idx[future]
|
|
67
|
+
results[idx] = future.result()
|
|
68
|
+
merged = "\n\n".join(r for r in results if r is not None)
|
|
69
|
+
else:
|
|
70
|
+
results = []
|
|
71
|
+
for img_path in tqdm(
|
|
72
|
+
images, desc=f"处理 {input_dir.name} 目录", file=sys.stderr
|
|
73
|
+
):
|
|
74
|
+
text = engine.parse_image(img_path)
|
|
75
|
+
results.append(text)
|
|
76
|
+
merged = "\n\n".join(results)
|
|
77
|
+
|
|
78
|
+
output_path = input_dir.with_suffix(".md")
|
|
79
|
+
output_path.write_text(merged, encoding="utf-8")
|
|
80
|
+
print(f"已保存: {output_path}")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _batch_pdfs(pdfs: list[Path], engine: OCREngine, concurrency: int = 1) -> None:
|
|
84
|
+
"""PDF 批量处理:逐文件调用 ocr_file(),各自输出 .md 文件。
|
|
85
|
+
|
|
86
|
+
每个 PDF 内部使用页面级并发(concurrency 传递给 ocr_file())。
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
pdfs: 已排序的 PDF 文件路径列表。
|
|
90
|
+
engine: OCR 引擎实例。
|
|
91
|
+
concurrency: 页面级并发数,默认 1(串行逐页)。
|
|
92
|
+
"""
|
|
93
|
+
for pdf_path in pdfs:
|
|
94
|
+
ocr_file(
|
|
95
|
+
input_path=pdf_path, engine=engine, pages=None, concurrency=concurrency
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def ocr_directory(input_dir: Path, engine: OCREngine, concurrency: int = 1) -> None:
|
|
100
|
+
"""批量处理目录下的图片或 PDF 文件。
|
|
101
|
+
|
|
102
|
+
目录下只能有一种类型的文件(全图片或全 PDF),混合目录直接报错。
|
|
103
|
+
|
|
104
|
+
根据目录内容与引擎类型自动选择处理分支:
|
|
105
|
+
- 仅有图片 + 原生 PDF 引擎 → 图片合并为 PDF 再解析(更高效)
|
|
106
|
+
- 仅有图片 + 其他引擎 → 图片批量流程(合并输出)
|
|
107
|
+
- 仅有 PDF → PDF 批量流程(各自输出)
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
input_dir: 输入目录路径。
|
|
111
|
+
engine: OCR 引擎实例。
|
|
112
|
+
concurrency: 并发数,默认 1(串行)。
|
|
113
|
+
|
|
114
|
+
Raises:
|
|
115
|
+
FileNotFoundError: 目录不存在。
|
|
116
|
+
ValueError: 目录中没有可处理的文件,或包含混合文件类型。
|
|
117
|
+
"""
|
|
118
|
+
if not input_dir.exists():
|
|
119
|
+
raise FileNotFoundError(f"目录不存在: {input_dir}")
|
|
120
|
+
if not input_dir.is_dir():
|
|
121
|
+
raise NotADirectoryError(f"不是目录: {input_dir}")
|
|
122
|
+
|
|
123
|
+
images, pdfs = _collect_files(input_dir)
|
|
124
|
+
|
|
125
|
+
# 严格检查:不允许混合文件类型
|
|
126
|
+
if images and pdfs:
|
|
127
|
+
raise ValueError(
|
|
128
|
+
f"目录中包含混合文件类型(图片和 PDF),请确保目录下只有一种类型的文件。\n"
|
|
129
|
+
f" 图片: {len(images)} 个 (.png/.jpg/.jpeg)\n"
|
|
130
|
+
f" PDF: {len(pdfs)} 个 (.pdf)"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
if not images and not pdfs:
|
|
134
|
+
raise ValueError(f"目录中没有可处理的图片或 PDF 文件: {input_dir}")
|
|
135
|
+
|
|
136
|
+
if images:
|
|
137
|
+
_batch_images(input_dir, images, engine, concurrency)
|
|
138
|
+
else:
|
|
139
|
+
_batch_pdfs(pdfs, engine, concurrency)
|
multi_ocr/cli.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Multi-OCR: 将 PDF 和图片转换为 markdown 文档。
|
|
2
|
+
|
|
3
|
+
支持多种 OCR 引擎:
|
|
4
|
+
- SiliconFlow(DeepSeek-OCR / PaddleOCR-VL-1.5)
|
|
5
|
+
- DashScope(Qwen-VL-OCR)
|
|
6
|
+
- LiteParse(本地 PDF 解析)
|
|
7
|
+
- Ollama(本地 DeepSeek-OCR)
|
|
8
|
+
|
|
9
|
+
用法:
|
|
10
|
+
python main.py <path> [--model MODEL] [--pages PAGES] [--api-key KEY] [--ollama-url URL]
|
|
11
|
+
|
|
12
|
+
<path> 可以是文件或目录:
|
|
13
|
+
- 文件:单文件模式,自动判断图片/PDF
|
|
14
|
+
- 目录:批量模式,目录下只能有一种类型的文件(全图片或全 PDF)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from multi_ocr.engines import get_engine
|
|
23
|
+
from multi_ocr.single import ocr_file
|
|
24
|
+
from multi_ocr.batch import ocr_directory
|
|
25
|
+
|
|
26
|
+
DEFAULT_MODEL = "silicon-deepseek-ocr"
|
|
27
|
+
|
|
28
|
+
# --model 缩写 → (provider, model_name, description)
|
|
29
|
+
MODEL_MAP: dict[str, tuple[str, str, str]] = {
|
|
30
|
+
"silicon-deepseek-ocr": (
|
|
31
|
+
"siliconflow",
|
|
32
|
+
"deepseek-ai/DeepSeek-OCR",
|
|
33
|
+
"SiliconFlow + DeepSeek-OCR",
|
|
34
|
+
),
|
|
35
|
+
"silicon-paddle-ocr": (
|
|
36
|
+
"siliconflow",
|
|
37
|
+
"PaddlePaddle/PaddleOCR-VL-1.5",
|
|
38
|
+
"SiliconFlow + PaddleOCR-VL-1.5",
|
|
39
|
+
),
|
|
40
|
+
"dashscope-qwen-vl-ocr": ("dashscope", "qwen-vl-ocr", "DashScope + Qwen-VL-OCR"),
|
|
41
|
+
"liteparse": ("liteparse", "", "LiteParse (本地 PDF 解析)"),
|
|
42
|
+
"ollama-deepseek-ocr": (
|
|
43
|
+
"ollama",
|
|
44
|
+
"deepseek-ocr:latest",
|
|
45
|
+
"Ollama + DeepSeek-OCR",
|
|
46
|
+
),
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
# provider -> 环境变量名
|
|
50
|
+
_PROVIDER_ENV: dict[str, str] = {
|
|
51
|
+
"siliconflow": "SILICONFLOW_API_KEY",
|
|
52
|
+
"dashscope": "DASHSCOPE_API_KEY",
|
|
53
|
+
# ollama / liteparse 为本地引擎,不需要 API Key
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _get_api_key(provider: str, cli_key: str | None) -> str | None:
|
|
58
|
+
"""获取 API Key:命令行 > provider 专用环境变量。
|
|
59
|
+
Ollama / LiteParse 无需 API Key,返回空字符串。
|
|
60
|
+
"""
|
|
61
|
+
if cli_key:
|
|
62
|
+
return cli_key
|
|
63
|
+
if provider in ("liteparse", "ollama"):
|
|
64
|
+
return "" # 本地引擎,无需 API Key
|
|
65
|
+
env_var = _PROVIDER_ENV[provider]
|
|
66
|
+
return os.environ.get(env_var)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _build_model_help() -> str:
|
|
70
|
+
"""动态生成 --model 参数的帮助文本。"""
|
|
71
|
+
lines = [f"模型缩写(默认: {DEFAULT_MODEL})\n可用模型:"]
|
|
72
|
+
for alias, (_prov, _model, desc) in MODEL_MAP.items():
|
|
73
|
+
lines.append(f" {alias:<25} → {desc}")
|
|
74
|
+
return "\n".join(lines)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _build_api_key_help() -> str:
|
|
78
|
+
"""动态生成 --api-key 参数的帮助文本。"""
|
|
79
|
+
lines = ["API Key(根据 --model 自动读取对应环境变量):"]
|
|
80
|
+
for provider, env_var in _PROVIDER_ENV.items():
|
|
81
|
+
lines.append(f" {provider} 模型 → {env_var}")
|
|
82
|
+
return "\n".join(lines)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def main() -> None:
|
|
86
|
+
parser = argparse.ArgumentParser(
|
|
87
|
+
description="将 PDF 或图片转换为文字。默认使用 SiliconFlow + DeepSeek-OCR。",
|
|
88
|
+
formatter_class=argparse.RawTextHelpFormatter,
|
|
89
|
+
)
|
|
90
|
+
parser.add_argument(
|
|
91
|
+
"input",
|
|
92
|
+
type=Path,
|
|
93
|
+
help="输入文件路径(PDF 或图片)或目录路径(批量处理)",
|
|
94
|
+
)
|
|
95
|
+
parser.add_argument(
|
|
96
|
+
"--model",
|
|
97
|
+
default=DEFAULT_MODEL,
|
|
98
|
+
help=_build_model_help(),
|
|
99
|
+
)
|
|
100
|
+
parser.add_argument(
|
|
101
|
+
"--pages",
|
|
102
|
+
default=None,
|
|
103
|
+
help="页码范围,如 1-3,5(仅对单文件 PDF 有效)",
|
|
104
|
+
)
|
|
105
|
+
parser.add_argument(
|
|
106
|
+
"--api-key",
|
|
107
|
+
default=None,
|
|
108
|
+
help=_build_api_key_help(),
|
|
109
|
+
)
|
|
110
|
+
parser.add_argument(
|
|
111
|
+
"--ollama-url",
|
|
112
|
+
default=None,
|
|
113
|
+
help="Ollama 服务地址(默认读取 OLLAMA_BASE_URL 环境变量,都没有则用 http://127.0.0.1:11434)",
|
|
114
|
+
)
|
|
115
|
+
parser.add_argument(
|
|
116
|
+
"-j",
|
|
117
|
+
"--concurrency",
|
|
118
|
+
type=int,
|
|
119
|
+
default=1,
|
|
120
|
+
help="并发数量(默认: 1,即串行处理)",
|
|
121
|
+
)
|
|
122
|
+
args = parser.parse_args()
|
|
123
|
+
|
|
124
|
+
input_path: Path = args.input
|
|
125
|
+
|
|
126
|
+
# 校验路径存在
|
|
127
|
+
if not input_path.exists():
|
|
128
|
+
print(f"错误: 路径不存在: {input_path}", file=sys.stderr)
|
|
129
|
+
sys.exit(1)
|
|
130
|
+
|
|
131
|
+
# 判断模式:目录 → 批量,文件 → 单文件
|
|
132
|
+
batch_mode = input_path.is_dir()
|
|
133
|
+
|
|
134
|
+
# --pages 约束
|
|
135
|
+
pages: str | None = args.pages
|
|
136
|
+
if batch_mode and pages:
|
|
137
|
+
print("⚠️ --pages 在批量模式下忽略", file=sys.stderr)
|
|
138
|
+
pages = None
|
|
139
|
+
|
|
140
|
+
# 解析 --model 缩写 → provider + model_name
|
|
141
|
+
entry = MODEL_MAP.get(args.model)
|
|
142
|
+
if entry is None:
|
|
143
|
+
valid = ", ".join(MODEL_MAP.keys())
|
|
144
|
+
print(
|
|
145
|
+
f"无效的 --model 值 '{args.model}',可用: {valid}",
|
|
146
|
+
file=sys.stderr,
|
|
147
|
+
)
|
|
148
|
+
sys.exit(1)
|
|
149
|
+
provider, model_name, _desc = entry
|
|
150
|
+
|
|
151
|
+
# API Key:命令行 > 环境变量(Ollama / LiteParse 跳过)
|
|
152
|
+
api_key = _get_api_key(provider, args.api_key)
|
|
153
|
+
if not api_key and provider not in ("liteparse", "ollama"):
|
|
154
|
+
env_var = _PROVIDER_ENV.get(provider, "")
|
|
155
|
+
print(
|
|
156
|
+
f"请设置 {env_var} 环境变量或使用 --api-key 参数。",
|
|
157
|
+
file=sys.stderr,
|
|
158
|
+
)
|
|
159
|
+
sys.exit(1)
|
|
160
|
+
|
|
161
|
+
# 获取 Ollama base_url(仅 ollama 引擎使用)
|
|
162
|
+
ollama_url = args.ollama_url or os.environ.get("OLLAMA_BASE_URL")
|
|
163
|
+
|
|
164
|
+
# 并发数
|
|
165
|
+
concurrency: int = args.concurrency
|
|
166
|
+
if concurrency < 1:
|
|
167
|
+
print("错误: --concurrency 必须 >= 1", file=sys.stderr)
|
|
168
|
+
sys.exit(1)
|
|
169
|
+
|
|
170
|
+
# 获取引擎
|
|
171
|
+
try:
|
|
172
|
+
engine = get_engine(
|
|
173
|
+
provider=provider,
|
|
174
|
+
model=model_name,
|
|
175
|
+
api_key=api_key,
|
|
176
|
+
base_url=ollama_url if provider == "ollama" else None,
|
|
177
|
+
)
|
|
178
|
+
except ValueError as e:
|
|
179
|
+
print(f"引擎初始化失败: {e}", file=sys.stderr)
|
|
180
|
+
sys.exit(1)
|
|
181
|
+
|
|
182
|
+
# 执行 OCR
|
|
183
|
+
try:
|
|
184
|
+
if batch_mode:
|
|
185
|
+
ocr_directory(
|
|
186
|
+
input_dir=input_path,
|
|
187
|
+
engine=engine,
|
|
188
|
+
concurrency=concurrency,
|
|
189
|
+
)
|
|
190
|
+
else:
|
|
191
|
+
ocr_file(
|
|
192
|
+
input_path=input_path,
|
|
193
|
+
engine=engine,
|
|
194
|
+
pages=pages,
|
|
195
|
+
concurrency=concurrency,
|
|
196
|
+
)
|
|
197
|
+
except (FileNotFoundError, NotADirectoryError, ValueError) as e:
|
|
198
|
+
print(f"错误: {e}", file=sys.stderr)
|
|
199
|
+
sys.exit(1)
|
|
200
|
+
except Exception as e:
|
|
201
|
+
print(f"未预期的错误: {e}", file=sys.stderr)
|
|
202
|
+
sys.exit(1)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
if __name__ == "__main__":
|
|
206
|
+
main()
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from multi_ocr.engines.base import OCREngine
|
|
2
|
+
from multi_ocr.engines.dashscope import DashScopeEngine
|
|
3
|
+
from multi_ocr.engines.ollama import OllamaEngine
|
|
4
|
+
from multi_ocr.engines.siliconflow import SiliconFlowEngine
|
|
5
|
+
|
|
6
|
+
# LiteParse 为可选依赖,仅在已安装时注册
|
|
7
|
+
try:
|
|
8
|
+
from multi_ocr.engines.liteparse import LiteParseEngine
|
|
9
|
+
|
|
10
|
+
_HAS_LITEPARSE = True
|
|
11
|
+
except ImportError:
|
|
12
|
+
_HAS_LITEPARSE = False
|
|
13
|
+
|
|
14
|
+
# 引擎注册表:provider 名 -> Engine 类
|
|
15
|
+
# 添加新提供商时只需在此注册
|
|
16
|
+
_registry: dict[str, type[OCREngine]] = {
|
|
17
|
+
"dashscope": DashScopeEngine,
|
|
18
|
+
"ollama": OllamaEngine,
|
|
19
|
+
"siliconflow": SiliconFlowEngine,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if _HAS_LITEPARSE:
|
|
23
|
+
_registry["liteparse"] = LiteParseEngine
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_engine(
|
|
27
|
+
provider: str, model: str, api_key: str, base_url: str | None = None
|
|
28
|
+
) -> OCREngine:
|
|
29
|
+
"""工厂函数:按 provider 名查找并实例化 OCR 引擎。
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
provider: OCR 服务提供商名称(如 "siliconflow")。
|
|
33
|
+
model: 模型名称(如 "deepseek-ai/DeepSeek-OCR")。
|
|
34
|
+
api_key: API 密钥。
|
|
35
|
+
base_url: 自定义 API 地址(当前仅 ollama 引擎使用)。
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
OCREngine 实例。
|
|
39
|
+
|
|
40
|
+
Raises:
|
|
41
|
+
ValueError: 如果 provider 未注册。
|
|
42
|
+
"""
|
|
43
|
+
engine_cls = _registry.get(provider)
|
|
44
|
+
if engine_cls is None:
|
|
45
|
+
available = ", ".join(_registry.keys())
|
|
46
|
+
raise ValueError(f"未知的 OCR 提供商: {provider},当前可用: {available}")
|
|
47
|
+
if base_url is not None:
|
|
48
|
+
return engine_cls(model=model, api_key=api_key, base_url=base_url)
|
|
49
|
+
return engine_cls(model=model, api_key=api_key)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import shutil
|
|
2
|
+
from abc import ABC, abstractmethod
|
|
3
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Callable
|
|
6
|
+
|
|
7
|
+
from multi_ocr.pdf_utils import split_pdf
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class OCREngine(ABC):
|
|
11
|
+
"""OCR 引擎抽象基类。所有 OCR 引擎必须实现此接口。"""
|
|
12
|
+
|
|
13
|
+
@abstractmethod
|
|
14
|
+
def parse_image(self, image_path: Path) -> str:
|
|
15
|
+
"""对单张图片进行 OCR,返回 markdown 文本。
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
image_path: 图片文件路径。
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
识别出的 markdown 文本内容。
|
|
22
|
+
"""
|
|
23
|
+
...
|
|
24
|
+
|
|
25
|
+
def parse_pdf(
|
|
26
|
+
self,
|
|
27
|
+
pdf_path: Path,
|
|
28
|
+
pages: str | None = None,
|
|
29
|
+
progress_callback: Callable[[], None] | None = None,
|
|
30
|
+
concurrency: int = 1,
|
|
31
|
+
) -> str:
|
|
32
|
+
"""解析 PDF,返回 markdown 文本。
|
|
33
|
+
|
|
34
|
+
默认实现:拆页 → 调用 parse_image() → 合并结果。
|
|
35
|
+
子类可覆盖以提供原生 PDF 解析(如 LiteParse)。
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
pdf_path: PDF 文件路径。
|
|
39
|
+
pages: 页码范围字符串(如 "1-3,5"),None 表示全部。
|
|
40
|
+
progress_callback: 每处理完一页时调用(可选)。
|
|
41
|
+
concurrency: 并发数,默认 1(串行),>1 时使用线程池并发处理多页。
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
解析出的 markdown 文本内容。
|
|
45
|
+
"""
|
|
46
|
+
image_paths, page_labels = split_pdf(pdf_path, pages)
|
|
47
|
+
try:
|
|
48
|
+
if concurrency > 1:
|
|
49
|
+
results = self._parse_pages_concurrent(
|
|
50
|
+
image_paths, page_labels, concurrency, progress_callback
|
|
51
|
+
)
|
|
52
|
+
else:
|
|
53
|
+
results = []
|
|
54
|
+
for img_path, label in zip(image_paths, page_labels):
|
|
55
|
+
text = self.parse_image(img_path)
|
|
56
|
+
results.append(f"--- 第 {label} 页 ---\n{text}")
|
|
57
|
+
if progress_callback:
|
|
58
|
+
progress_callback()
|
|
59
|
+
return "\n\n".join(results)
|
|
60
|
+
finally:
|
|
61
|
+
if image_paths:
|
|
62
|
+
shutil.rmtree(image_paths[0].parent, ignore_errors=True)
|
|
63
|
+
|
|
64
|
+
def _parse_pages_concurrent(
|
|
65
|
+
self,
|
|
66
|
+
image_paths: list[Path],
|
|
67
|
+
page_labels: list[int],
|
|
68
|
+
concurrency: int,
|
|
69
|
+
progress_callback: Callable[[], None] | None,
|
|
70
|
+
) -> list[str]:
|
|
71
|
+
"""并发解析多页图片,保持页码顺序。
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
image_paths: 图片路径列表。
|
|
75
|
+
page_labels: 页码标签列表(1-based)。
|
|
76
|
+
concurrency: 并发线程数。
|
|
77
|
+
progress_callback: 每完成一页时调用(可选)。
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
按页码顺序排列的结果列表,每项格式为 "--- 第 N 页 ---\n{text}"。
|
|
81
|
+
"""
|
|
82
|
+
results: list[str | None] = [None] * len(image_paths)
|
|
83
|
+
with ThreadPoolExecutor(max_workers=concurrency) as executor:
|
|
84
|
+
future_to_idx = {
|
|
85
|
+
executor.submit(self.parse_image, img_path): idx
|
|
86
|
+
for idx, img_path in enumerate(image_paths)
|
|
87
|
+
}
|
|
88
|
+
for future in as_completed(future_to_idx):
|
|
89
|
+
idx = future_to_idx[future]
|
|
90
|
+
text = future.result()
|
|
91
|
+
results[idx] = f"--- 第 {page_labels[idx]} 页 ---\n{text}"
|
|
92
|
+
if progress_callback:
|
|
93
|
+
progress_callback()
|
|
94
|
+
return [r for r in results if r is not None]
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import re
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from openai import OpenAI
|
|
6
|
+
|
|
7
|
+
from multi_ocr.engines.base import OCREngine
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DashScopeEngine(OCREngine):
|
|
11
|
+
"""DashScope (阿里云百炼) OCR 引擎,兼容 OpenAI SDK。
|
|
12
|
+
|
|
13
|
+
支持 Qwen-VL-OCR 模型,通过 OpenAI 兼容接口调用。
|
|
14
|
+
参考文档:https://help.aliyun.com/zh/model-studio/qwen-ocr
|
|
15
|
+
|
|
16
|
+
默认为通用 OCR 场景,如需使用内置任务(高精识别、表格解析、信息抽取等),
|
|
17
|
+
可参照文档在 prompt 中传入对应指令。
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
|
21
|
+
|
|
22
|
+
# 模型名称 -> 提示词映射(按前缀匹配)
|
|
23
|
+
_PROMPT_MAP: dict[str, str] = {
|
|
24
|
+
"qwen-vl-ocr": "OCR this image and output in markdown format.",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
_FALLBACK_PROMPT = "OCR this image and output in markdown format."
|
|
28
|
+
|
|
29
|
+
def __init__(self, model: str, api_key: str, base_url: str | None = None) -> None:
|
|
30
|
+
self._model = model
|
|
31
|
+
self._client = OpenAI(
|
|
32
|
+
api_key=api_key,
|
|
33
|
+
base_url=base_url or self.BASE_URL,
|
|
34
|
+
timeout=120.0,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
def _get_prompt(self) -> str:
|
|
38
|
+
"""根据模型名称匹配对应的提示词,未匹配则使用默认提示词。"""
|
|
39
|
+
for prefix, prompt in self._PROMPT_MAP.items():
|
|
40
|
+
if prefix in self._model:
|
|
41
|
+
return prompt
|
|
42
|
+
return self._FALLBACK_PROMPT
|
|
43
|
+
|
|
44
|
+
def parse_image(self, image_path: Path) -> str:
|
|
45
|
+
with open(image_path, "rb") as f:
|
|
46
|
+
image_data = base64.b64encode(f.read()).decode("utf-8")
|
|
47
|
+
|
|
48
|
+
# 根据扩展名确定 MIME 类型
|
|
49
|
+
suffix = image_path.suffix.lower()
|
|
50
|
+
mime_map = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg"}
|
|
51
|
+
mime_type = mime_map.get(suffix, "image/png")
|
|
52
|
+
|
|
53
|
+
data_url = f"data:{mime_type};base64,{image_data}"
|
|
54
|
+
|
|
55
|
+
prompt = self._get_prompt()
|
|
56
|
+
|
|
57
|
+
response = self._client.chat.completions.create(
|
|
58
|
+
model=self._model,
|
|
59
|
+
messages=[
|
|
60
|
+
{
|
|
61
|
+
"role": "user",
|
|
62
|
+
"content": [
|
|
63
|
+
{
|
|
64
|
+
"type": "image_url",
|
|
65
|
+
"image_url": {"url": data_url},
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"type": "text",
|
|
69
|
+
"text": prompt,
|
|
70
|
+
},
|
|
71
|
+
],
|
|
72
|
+
}
|
|
73
|
+
],
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
content = response.choices[0].message.content
|
|
77
|
+
if not content:
|
|
78
|
+
return ""
|
|
79
|
+
# 清理模型可能输出的特殊标记
|
|
80
|
+
content = re.sub(r"<\|/?ref\|>", "", content)
|
|
81
|
+
content = re.sub(r"<\|/?det\|>", "", content)
|
|
82
|
+
# 剥离 markdown 代码块包裹(如 ```markdown ... ```)
|
|
83
|
+
content = re.sub(r"^```(?:markdown)?\s*\n", "", content, count=1)
|
|
84
|
+
content = re.sub(r"\n```\s*$", "", content)
|
|
85
|
+
return content.strip()
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
import tempfile
|
|
3
|
+
from typing import Callable
|
|
4
|
+
|
|
5
|
+
from liteparse import LiteParse as LiteParseLib
|
|
6
|
+
|
|
7
|
+
from multi_ocr.engines.base import OCREngine
|
|
8
|
+
from multi_ocr.pdf_utils import images_to_pdf
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class LiteParseEngine(OCREngine):
|
|
12
|
+
"""LiteParse 本地 PDF 解析引擎。
|
|
13
|
+
|
|
14
|
+
基于 Rust 实现,无需 API Key、无需联网,直接解析 PDF 并输出 markdown。
|
|
15
|
+
图片通过自动转换为 PDF 后解析来支持。
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
model: str = "",
|
|
21
|
+
api_key: str = "",
|
|
22
|
+
base_url: str | None = None,
|
|
23
|
+
) -> None:
|
|
24
|
+
# model / api_key / base_url 不使用,保留参数以兼容工厂函数签名
|
|
25
|
+
self._model = model
|
|
26
|
+
self._api_key = api_key
|
|
27
|
+
|
|
28
|
+
def parse_image(self, image_path: Path) -> str:
|
|
29
|
+
"""将图片转为 PDF 后解析,返回 markdown 文本。"""
|
|
30
|
+
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as f:
|
|
31
|
+
tmp_pdf = Path(f.name)
|
|
32
|
+
try:
|
|
33
|
+
images_to_pdf([image_path], tmp_pdf)
|
|
34
|
+
return self.parse_pdf(tmp_pdf)
|
|
35
|
+
finally:
|
|
36
|
+
tmp_pdf.unlink(missing_ok=True)
|
|
37
|
+
|
|
38
|
+
def parse_pdf(
|
|
39
|
+
self,
|
|
40
|
+
pdf_path: Path,
|
|
41
|
+
pages: str | None = None,
|
|
42
|
+
progress_callback: Callable[[], None] | None = None,
|
|
43
|
+
concurrency: int = 1,
|
|
44
|
+
) -> str:
|
|
45
|
+
"""直接解析 PDF,返回 markdown 文本。
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
pdf_path: PDF 文件路径。
|
|
49
|
+
pages: 页码范围字符串(如 "1-3,5"),None 表示全部。
|
|
50
|
+
concurrency: 并发数(LiteParse 为本地引擎,忽略此参数)。
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
解析出的 markdown 文本内容。
|
|
54
|
+
"""
|
|
55
|
+
kwargs: dict = {}
|
|
56
|
+
if pages:
|
|
57
|
+
kwargs["target_pages"] = pages
|
|
58
|
+
parser = LiteParseLib(output_format="markdown", **kwargs)
|
|
59
|
+
result = parser.parse(str(pdf_path))
|
|
60
|
+
return result.text
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from ollama import Client
|
|
5
|
+
|
|
6
|
+
from multi_ocr.engines.base import OCREngine
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class OllamaEngine(OCREngine):
|
|
10
|
+
"""Ollama 本地 OCR 引擎。
|
|
11
|
+
|
|
12
|
+
通过 Ollama 调用本地部署的视觉模型进行 OCR 识别。
|
|
13
|
+
无需 API Key,支持自定义 base_url 连接远程 Ollama 实例。
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
DEFAULT_BASE_URL = "http://127.0.0.1:11434"
|
|
17
|
+
|
|
18
|
+
# 模型名称 -> 提示词映射(按前缀匹配)
|
|
19
|
+
_PROMPT_MAP: dict[str, str] = {
|
|
20
|
+
"deepseek-ocr": "<image>\nFree OCR. Output in markdown format.",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
_FALLBACK_PROMPT = "<image>\nFree OCR. Output in markdown format."
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
model: str,
|
|
28
|
+
api_key: str = "",
|
|
29
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
30
|
+
) -> None:
|
|
31
|
+
self._model = model
|
|
32
|
+
self._client = Client(host=base_url)
|
|
33
|
+
|
|
34
|
+
def _get_prompt(self) -> str:
|
|
35
|
+
"""根据模型名称匹配对应的提示词,未匹配则使用默认提示词。"""
|
|
36
|
+
for prefix, prompt in self._PROMPT_MAP.items():
|
|
37
|
+
if prefix in self._model:
|
|
38
|
+
return prompt
|
|
39
|
+
return self._FALLBACK_PROMPT
|
|
40
|
+
|
|
41
|
+
def parse_image(self, image_path: Path) -> str:
|
|
42
|
+
prompt = self._get_prompt()
|
|
43
|
+
|
|
44
|
+
response = self._client.chat(
|
|
45
|
+
model=self._model,
|
|
46
|
+
messages=[
|
|
47
|
+
{
|
|
48
|
+
"role": "user",
|
|
49
|
+
"content": prompt,
|
|
50
|
+
"images": [str(image_path)],
|
|
51
|
+
}
|
|
52
|
+
],
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
content = response.message.content
|
|
56
|
+
if not content:
|
|
57
|
+
return ""
|
|
58
|
+
# 清理模型可能输出的特殊标记
|
|
59
|
+
content = re.sub(r"<\|/?ref\|>", "", content)
|
|
60
|
+
content = re.sub(r"<\|/?det\|>", "", content)
|
|
61
|
+
return content.strip()
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import re
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from openai import OpenAI
|
|
6
|
+
|
|
7
|
+
from multi_ocr.engines.base import OCREngine
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SiliconFlowEngine(OCREngine):
|
|
11
|
+
"""SiliconFlow API OCR 引擎,兼容 OpenAI SDK。
|
|
12
|
+
|
|
13
|
+
不同模型使用不同的提示词以达到最佳识别效果。
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
BASE_URL = "https://api.siliconflow.cn/v1"
|
|
17
|
+
|
|
18
|
+
# 模型名称 -> 提示词映射(按前缀匹配)
|
|
19
|
+
_PROMPT_MAP: dict[str, str] = {
|
|
20
|
+
"deepseek-ai/DeepSeek-OCR": "<image>\nFree OCR. Output in markdown format.",
|
|
21
|
+
"PaddlePaddle/PaddleOCR-VL-1.5": "OCR this image and output in markdown format.",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
_FALLBACK_PROMPT = "OCR this image and output in markdown format."
|
|
25
|
+
|
|
26
|
+
def __init__(self, model: str, api_key: str, base_url: str | None = None) -> None:
|
|
27
|
+
self._model = model
|
|
28
|
+
self._client = OpenAI(
|
|
29
|
+
api_key=api_key,
|
|
30
|
+
base_url=base_url or self.BASE_URL,
|
|
31
|
+
timeout=120.0,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
def _get_prompt(self) -> str:
|
|
35
|
+
"""根据模型名称匹配对应的提示词,未匹配则使用默认提示词。"""
|
|
36
|
+
for prefix, prompt in self._PROMPT_MAP.items():
|
|
37
|
+
if prefix in self._model:
|
|
38
|
+
return prompt
|
|
39
|
+
return self._FALLBACK_PROMPT
|
|
40
|
+
|
|
41
|
+
def parse_image(self, image_path: Path) -> str:
|
|
42
|
+
with open(image_path, "rb") as f:
|
|
43
|
+
image_data = base64.b64encode(f.read()).decode("utf-8")
|
|
44
|
+
|
|
45
|
+
# 根据扩展名确定 MIME 类型
|
|
46
|
+
suffix = image_path.suffix.lower()
|
|
47
|
+
mime_map = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg"}
|
|
48
|
+
mime_type = mime_map.get(suffix, "image/png")
|
|
49
|
+
|
|
50
|
+
data_url = f"data:{mime_type};base64,{image_data}"
|
|
51
|
+
|
|
52
|
+
prompt = self._get_prompt()
|
|
53
|
+
|
|
54
|
+
response = self._client.chat.completions.create(
|
|
55
|
+
model=self._model,
|
|
56
|
+
temperature=0,
|
|
57
|
+
messages=[
|
|
58
|
+
{
|
|
59
|
+
"role": "user",
|
|
60
|
+
"content": [
|
|
61
|
+
{
|
|
62
|
+
"type": "image_url",
|
|
63
|
+
"image_url": {"url": data_url},
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"type": "text",
|
|
67
|
+
"text": prompt,
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
}
|
|
71
|
+
],
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
content = response.choices[0].message.content
|
|
75
|
+
if not content:
|
|
76
|
+
return ""
|
|
77
|
+
# 清理模型可能输出的特殊标记
|
|
78
|
+
content = re.sub(r"<\|/?ref\|>", "", content)
|
|
79
|
+
content = re.sub(r"<\|/?det\|>", "", content)
|
|
80
|
+
return content.strip()
|
multi_ocr/pdf_utils.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import tempfile
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import fitz # pymupdf
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def parse_pages(pages_str: str, total_pages: int) -> list[int]:
|
|
9
|
+
"""解析页码范围字符串,返回 0-based 页码列表。
|
|
10
|
+
|
|
11
|
+
格式:逗号分隔的页码和范围,如 "1,3-5,8"。
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
pages_str: 页码范围字符串。
|
|
15
|
+
total_pages: PDF 总页数。
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
0-based 页码列表(已去重并排序)。
|
|
19
|
+
|
|
20
|
+
Raises:
|
|
21
|
+
ValueError: 如果范围格式无效或页码越界。
|
|
22
|
+
"""
|
|
23
|
+
result: set[int] = set()
|
|
24
|
+
parts = [p.strip() for p in pages_str.split(",") if p.strip()]
|
|
25
|
+
|
|
26
|
+
for part in parts:
|
|
27
|
+
if "-" in part:
|
|
28
|
+
try:
|
|
29
|
+
start_str, end_str = part.split("-", 1)
|
|
30
|
+
start, end = int(start_str.strip()), int(end_str.strip())
|
|
31
|
+
except ValueError:
|
|
32
|
+
raise ValueError(f"无效的页码范围: {part}")
|
|
33
|
+
if start < 1 or end > total_pages or start > end:
|
|
34
|
+
raise ValueError(f"页码范围越界: {part}(总页数: {total_pages})")
|
|
35
|
+
for i in range(start, end + 1):
|
|
36
|
+
result.add(i - 1)
|
|
37
|
+
else:
|
|
38
|
+
try:
|
|
39
|
+
page = int(part)
|
|
40
|
+
except ValueError:
|
|
41
|
+
raise ValueError(f"无效的页码: {part}")
|
|
42
|
+
if page < 1 or page > total_pages:
|
|
43
|
+
raise ValueError(f"页码越界: {page}(总页数: {total_pages})")
|
|
44
|
+
result.add(page - 1)
|
|
45
|
+
|
|
46
|
+
return sorted(result)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def pdf_to_images(pdf_path: Path, pages: list[int] | None = None) -> list[Path]:
|
|
50
|
+
"""将 PDF 渲染为 PNG 图片列表。
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
pdf_path: PDF 文件路径。
|
|
54
|
+
pages: 要渲染的 0-based 页码列表,None 表示全部页。
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
临时 PNG 图片路径列表。
|
|
58
|
+
"""
|
|
59
|
+
doc = fitz.open(pdf_path)
|
|
60
|
+
total = doc.page_count
|
|
61
|
+
|
|
62
|
+
if total == 0:
|
|
63
|
+
doc.close()
|
|
64
|
+
return []
|
|
65
|
+
|
|
66
|
+
if pages is None:
|
|
67
|
+
pages = list(range(total))
|
|
68
|
+
|
|
69
|
+
tmp_dir = Path(tempfile.mkdtemp(prefix="multiocr_"))
|
|
70
|
+
image_paths: list[Path] = []
|
|
71
|
+
|
|
72
|
+
for page_num in pages:
|
|
73
|
+
if page_num < 0 or page_num >= total:
|
|
74
|
+
continue
|
|
75
|
+
page = doc[page_num]
|
|
76
|
+
pix = page.get_pixmap(dpi=200)
|
|
77
|
+
img_path = tmp_dir / f"page_{page_num + 1:04d}.png"
|
|
78
|
+
pix.save(str(img_path))
|
|
79
|
+
image_paths.append(img_path)
|
|
80
|
+
|
|
81
|
+
doc.close()
|
|
82
|
+
return image_paths
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def images_to_pdf(image_paths: list[Path], output_path: Path) -> Path:
|
|
86
|
+
"""将多张图片按顺序合并为一个 PDF 文件。
|
|
87
|
+
|
|
88
|
+
每张图片作为独立的一页,原图嵌入。
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
image_paths: 图片文件路径列表,按页面顺序排列。
|
|
92
|
+
output_path: 输出的 PDF 文件路径。
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
输出的 PDF 文件路径。
|
|
96
|
+
"""
|
|
97
|
+
pdf_doc = fitz.open()
|
|
98
|
+
try:
|
|
99
|
+
for img_path in image_paths:
|
|
100
|
+
img_doc = fitz.open(img_path)
|
|
101
|
+
page = img_doc[0]
|
|
102
|
+
pix = page.get_pixmap()
|
|
103
|
+
img_doc.close()
|
|
104
|
+
|
|
105
|
+
# 创建新页面,大小与图片一致
|
|
106
|
+
pdf_page = pdf_doc.new_page(width=pix.width, height=pix.height)
|
|
107
|
+
pdf_page.insert_image(
|
|
108
|
+
pdf_page.rect,
|
|
109
|
+
pixmap=pix,
|
|
110
|
+
)
|
|
111
|
+
pdf_doc.save(output_path)
|
|
112
|
+
return output_path
|
|
113
|
+
finally:
|
|
114
|
+
pdf_doc.close()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def split_pdf(pdf_path: Path, pages_str: str | None) -> tuple[list[Path], list[int]]:
|
|
118
|
+
"""拆解 PDF:验证页数、解析页码范围、渲染为图片。
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
pdf_path: PDF 文件路径。
|
|
122
|
+
pages_str: 页码范围字符串(如 "1-3,5"),None 表示全部。
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
(图片路径列表, 页码标签列表),标签为 1-based 页码。
|
|
126
|
+
|
|
127
|
+
Raises:
|
|
128
|
+
ValueError: 页码范围无效或越界。
|
|
129
|
+
"""
|
|
130
|
+
doc = fitz.open(pdf_path)
|
|
131
|
+
total = doc.page_count
|
|
132
|
+
doc.close()
|
|
133
|
+
|
|
134
|
+
if total == 0:
|
|
135
|
+
print("PDF 无内容。", file=sys.stderr)
|
|
136
|
+
sys.exit(1)
|
|
137
|
+
|
|
138
|
+
if pages_str:
|
|
139
|
+
page_indices = parse_pages(pages_str, total)
|
|
140
|
+
else:
|
|
141
|
+
page_indices = list(range(total))
|
|
142
|
+
|
|
143
|
+
images = pdf_to_images(pdf_path, page_indices)
|
|
144
|
+
labels = [i + 1 for i in page_indices]
|
|
145
|
+
|
|
146
|
+
return images, labels
|
multi_ocr/single.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import fitz
|
|
5
|
+
from tqdm import tqdm
|
|
6
|
+
|
|
7
|
+
from multi_ocr.engines.base import OCREngine
|
|
8
|
+
from multi_ocr.pdf_utils import parse_pages
|
|
9
|
+
|
|
10
|
+
# 支持的图片格式
|
|
11
|
+
_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg"}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _is_image(file_path: Path) -> bool:
|
|
15
|
+
return file_path.suffix.lower() in _IMAGE_EXTENSIONS
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _is_pdf(file_path: Path) -> bool:
|
|
19
|
+
return file_path.suffix.lower() == ".pdf"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def ocr_file(
|
|
23
|
+
input_path: Path,
|
|
24
|
+
engine: OCREngine,
|
|
25
|
+
pages: str | None = None,
|
|
26
|
+
concurrency: int = 1,
|
|
27
|
+
) -> str:
|
|
28
|
+
"""对 PDF 或图片文件执行 OCR。
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
input_path: 输入文件路径(PDF 或图片)。
|
|
32
|
+
engine: OCR 引擎实例。
|
|
33
|
+
pages: 页码范围字符串(仅对 PDF 有效),如 "1-3,5"。
|
|
34
|
+
concurrency: 并发数,默认 1(串行),仅对 PDF 多页有效。
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
合并后的 OCR 文本。
|
|
38
|
+
|
|
39
|
+
Raises:
|
|
40
|
+
FileNotFoundError: 文件不存在。
|
|
41
|
+
ValueError: 不支持的文件格式或页码范围无效。
|
|
42
|
+
"""
|
|
43
|
+
if not input_path.exists():
|
|
44
|
+
raise FileNotFoundError(f"文件不存在: {input_path}")
|
|
45
|
+
|
|
46
|
+
if _is_pdf(input_path):
|
|
47
|
+
# 获取实际要处理的页数
|
|
48
|
+
doc = fitz.open(input_path)
|
|
49
|
+
total_pages = doc.page_count
|
|
50
|
+
doc.close()
|
|
51
|
+
|
|
52
|
+
if pages:
|
|
53
|
+
page_indices = parse_pages(pages, total_pages)
|
|
54
|
+
actual_total = len(page_indices)
|
|
55
|
+
else:
|
|
56
|
+
actual_total = total_pages
|
|
57
|
+
|
|
58
|
+
if actual_total == 0:
|
|
59
|
+
merged = ""
|
|
60
|
+
else:
|
|
61
|
+
pbar = tqdm(
|
|
62
|
+
total=actual_total,
|
|
63
|
+
desc=f"识别 {input_path.name}",
|
|
64
|
+
file=sys.stderr,
|
|
65
|
+
unit="页",
|
|
66
|
+
)
|
|
67
|
+
try:
|
|
68
|
+
merged = engine.parse_pdf(
|
|
69
|
+
input_path,
|
|
70
|
+
pages,
|
|
71
|
+
progress_callback=lambda: pbar.update(1),
|
|
72
|
+
concurrency=concurrency,
|
|
73
|
+
)
|
|
74
|
+
# 确保进度条到达 100%(兼容 LiteParse 等无逐页回调的引擎)
|
|
75
|
+
pbar.update(pbar.total - pbar.n)
|
|
76
|
+
finally:
|
|
77
|
+
pbar.close()
|
|
78
|
+
elif _is_image(input_path):
|
|
79
|
+
if pages:
|
|
80
|
+
raise ValueError("图片不支持 --pages 页码范围,该参数仅对 PDF 有效")
|
|
81
|
+
merged = engine.parse_image(input_path)
|
|
82
|
+
else:
|
|
83
|
+
supported = ", ".join(_IMAGE_EXTENSIONS | {".pdf"})
|
|
84
|
+
raise ValueError(f"不支持的文件格式: {input_path.suffix},支持: {supported}")
|
|
85
|
+
|
|
86
|
+
# 输出
|
|
87
|
+
output_path = input_path.with_suffix(".md")
|
|
88
|
+
output_path.write_text(merged, encoding="utf-8")
|
|
89
|
+
print(f"已保存: {output_path}")
|
|
90
|
+
|
|
91
|
+
return merged
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: multi-ocr-py
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: 多引擎 OCR 工具包:将 PDF 和图片转换为 Markdown,支持 CLI 全局安装和 SDK 依赖两种使用方式
|
|
5
|
+
Project-URL: Repository, https://github.com/BlackBoxRecorder/multi-ocr
|
|
6
|
+
Author-email: Yinnan <im.yinnan@outlook.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: cli,markdown,ocr,pdf,vision-language-model
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Scientific/Engineering :: Image Recognition
|
|
18
|
+
Classifier: Topic :: Text Processing :: Markup :: Markdown
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Requires-Dist: liteparse
|
|
21
|
+
Requires-Dist: ollama
|
|
22
|
+
Requires-Dist: openai>=2.45.0
|
|
23
|
+
Requires-Dist: pymupdf>=1.28.0
|
|
24
|
+
Requires-Dist: tqdm>=4.67.0
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# multi-ocr
|
|
28
|
+
|
|
29
|
+
多引擎 OCR 工具包,将 PDF 和图片转换为 Markdown 文档。
|
|
30
|
+
|
|
31
|
+
- **CLI 工具**:全局安装后一行命令完成 OCR
|
|
32
|
+
- **SDK 依赖**:作为 Python 库集成到你的项目中
|
|
33
|
+
|
|
34
|
+
## 支持的引擎
|
|
35
|
+
|
|
36
|
+
| 引擎 | 说明 | 类型 |
|
|
37
|
+
|---|---|---|
|
|
38
|
+
| SiliconFlow + DeepSeek-OCR | 云端 OCR,精度高 | API |
|
|
39
|
+
| SiliconFlow + PaddleOCR-VL-1.5 | 云端 OCR,轻量快速 | API |
|
|
40
|
+
| DashScope + Qwen-VL-OCR | 阿里云百炼 | API |
|
|
41
|
+
| LiteParse | 本地 PDF 解析,无需联网 | 本地 |
|
|
42
|
+
| Ollama + DeepSeek-OCR | 本地部署 OCR | 本地 |
|
|
43
|
+
|
|
44
|
+
## 安装
|
|
45
|
+
|
|
46
|
+
### CLI 全局安装
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
uv tool install multi-ocr
|
|
50
|
+
# 或
|
|
51
|
+
pip install multi-ocr
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### SDK 依赖安装
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
pip install multi-ocr
|
|
58
|
+
# 或
|
|
59
|
+
uv add multi-ocr
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## CLI 使用
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
# 单文件 OCR
|
|
66
|
+
multi-ocr document.pdf
|
|
67
|
+
|
|
68
|
+
# 指定引擎和页码范围
|
|
69
|
+
multi-ocr document.pdf --model dashscope-qwen-vl-ocr --pages 1-5
|
|
70
|
+
|
|
71
|
+
# 批量处理目录
|
|
72
|
+
multi-ocr ./scans/ --model silicon-deepseek-ocr -j 4
|
|
73
|
+
|
|
74
|
+
# 查看帮助
|
|
75
|
+
multi-ocr --help
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### 环境变量
|
|
79
|
+
|
|
80
|
+
| 引擎 | 环境变量 |
|
|
81
|
+
|---|---|
|
|
82
|
+
| SiliconFlow | `SILICONFLOW_API_KEY` |
|
|
83
|
+
| DashScope | `DASHSCOPE_API_KEY` |
|
|
84
|
+
| Ollama | `OLLAMA_BASE_URL`(可选,默认 http://127.0.0.1:11434)|
|
|
85
|
+
|
|
86
|
+
也可通过 `--api-key` 参数直接传入。
|
|
87
|
+
|
|
88
|
+
## SDK 使用
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
from pathlib import Path
|
|
92
|
+
from multi_ocr import get_engine, ocr_file, OCREngine
|
|
93
|
+
|
|
94
|
+
# 创建引擎
|
|
95
|
+
engine = get_engine(
|
|
96
|
+
provider="siliconflow",
|
|
97
|
+
model="deepseek-ai/DeepSeek-OCR",
|
|
98
|
+
api_key="your-api-key",
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# 识别图片
|
|
102
|
+
text = engine.parse_image(Path("scan.jpg"))
|
|
103
|
+
|
|
104
|
+
# 识别 PDF
|
|
105
|
+
result = ocr_file(Path("document.pdf"), engine, pages="1-3")
|
|
106
|
+
|
|
107
|
+
# 批量处理目录
|
|
108
|
+
from multi_ocr import ocr_directory
|
|
109
|
+
ocr_directory(Path("./scans/"), engine, concurrency=4)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### 自定义引擎
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
from multi_ocr import OCREngine
|
|
116
|
+
|
|
117
|
+
class MyEngine(OCREngine):
|
|
118
|
+
def parse_image(self, image_path: Path) -> str:
|
|
119
|
+
# 你的 OCR 逻辑
|
|
120
|
+
return "recognized text"
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## 要求
|
|
124
|
+
|
|
125
|
+
- Python >= 3.11
|
|
126
|
+
|
|
127
|
+
## 协议
|
|
128
|
+
|
|
129
|
+
MIT License
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
multi_ocr/__init__.py,sha256=rJQXDFWGvtpHlr-AEr1jWaAIlcxT4qPTycqYl13ghCg,631
|
|
2
|
+
multi_ocr/batch.py,sha256=m5p6sAvyFUiKNxIzVMYDeCziIhDT3ifftxwPeRR9cpU,4855
|
|
3
|
+
multi_ocr/cli.py,sha256=kDkYWCDTvjJeCd_DkSM95xfW5FUPuKYnDENOl9Ity-0,6299
|
|
4
|
+
multi_ocr/pdf_utils.py,sha256=cArl4cM7WXGCHCpU95JG9e3YBO2daFDffPCh0oMCDbc,4205
|
|
5
|
+
multi_ocr/single.py,sha256=oVQmzz9vOUakp55_6SVSh-PXcZk80EeZ4fBgU0Oz_Lo,2707
|
|
6
|
+
multi_ocr/engines/__init__.py,sha256=9FreP7hz1EoGr5GJp3QwzULYhiJ-UJnHZTFXm3Zx5-s,1616
|
|
7
|
+
multi_ocr/engines/base.py,sha256=pih7Thc65s0pT64NB7C0uYt40kZKYmlAEcAJ36e1yEA,3421
|
|
8
|
+
multi_ocr/engines/dashscope.py,sha256=sdqAxUSc5eGNM68BRSvj_7ypZfAfDsS9XjZppRj-OZ4,2950
|
|
9
|
+
multi_ocr/engines/liteparse.py,sha256=XJKHcpLFhHxR1JQDtXELS2iC8ofjvD-HeB54F9cB-D0,1924
|
|
10
|
+
multi_ocr/engines/ollama.py,sha256=020oE0HgODYY6ScZi0GNBhAbiyhNtJ0K0NhLvnVnQWc,1777
|
|
11
|
+
multi_ocr/engines/siliconflow.py,sha256=NhyJNXGaSCyqnbEB8eBK0Z5ibWg4ZDrM2xgujhWVNd4,2620
|
|
12
|
+
multi_ocr_py-0.1.0.dist-info/METADATA,sha256=hyBGVL_6Kbxezn4nMpDDHc_kzg_0mL1ub0_ib5P_tK0,3084
|
|
13
|
+
multi_ocr_py-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
14
|
+
multi_ocr_py-0.1.0.dist-info/entry_points.txt,sha256=vAMqg_Jw9QxagIodm_DGtlYTShMXq3sSqoVHJkitLW4,49
|
|
15
|
+
multi_ocr_py-0.1.0.dist-info/licenses/LICENSE,sha256=UuDhUgdWFxIjPnphLhz1Ped4u4oSGDpfLLoyO9v8Lks,1063
|
|
16
|
+
multi_ocr_py-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yinnan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|