feat:添加对pdf的识别支持
This commit is contained in:
parent
d63fbbd9c7
commit
8e81cd4d0e
|
|
@ -12,3 +12,6 @@ wheels/
|
||||||
# Generated benchmark results
|
# Generated benchmark results
|
||||||
benchmarks/gpu/*.json
|
benchmarks/gpu/*.json
|
||||||
!benchmarks/gpu/.gitkeep
|
!benchmarks/gpu/.gitkeep
|
||||||
|
|
||||||
|
# OCR outputs
|
||||||
|
outputs/
|
||||||
|
|
|
||||||
92
README.md
92
README.md
|
|
@ -9,11 +9,14 @@
|
||||||
```
|
```
|
||||||
ocr-VL1.6/
|
ocr-VL1.6/
|
||||||
├── main.py # CPU 单图 OCR + Benchmark
|
├── main.py # CPU 单图 OCR + Benchmark
|
||||||
├── batch_ocr.py # CPU 批量 OCR(系统友好的多进程版本)
|
├── batch_ocr.py # CPU 批量图片 OCR(系统友好的多进程版本)
|
||||||
|
├── pdf_ocr.py # CPU PDF OCR(逐页、可恢复)
|
||||||
|
├── pdf_ocr_core.py # CPU/GPU 共用的 PDF 渲染、恢复和导出逻辑
|
||||||
├── pyproject.toml # CPU 项目依赖
|
├── pyproject.toml # CPU 项目依赖
|
||||||
├── uv.lock # CPU 锁文件
|
├── uv.lock # CPU 锁文件
|
||||||
├── gpu/ # 独立 GPU 子项目
|
├── gpu/ # 独立 GPU 子项目
|
||||||
│ ├── main.py # GPU 单图 Benchmark
|
│ ├── main.py # GPU 单图 Benchmark
|
||||||
|
│ ├── pdf_ocr.py # GPU PDF OCR(复用公共核心)
|
||||||
│ ├── verify_env.py # CUDA 环境与计算验证
|
│ ├── verify_env.py # CUDA 环境与计算验证
|
||||||
│ ├── setup_env.py # 按 CUDA Wheel 类型创建环境
|
│ ├── setup_env.py # 按 CUDA Wheel 类型创建环境
|
||||||
│ ├── pyproject.toml # GPU 独立依赖
|
│ ├── pyproject.toml # GPU 独立依赖
|
||||||
|
|
@ -86,6 +89,93 @@ uv run --project gpu python gpu/main.py --warmup 1 --rounds 3
|
||||||
|
|
||||||
GPU Benchmark JSON 写入 `benchmarks/gpu/`。详细说明见 [`gpu/README.md`](gpu/README.md)。
|
GPU Benchmark JSON 写入 `benchmarks/gpu/`。详细说明见 [`gpu/README.md`](gpu/README.md)。
|
||||||
|
|
||||||
|
## PDF OCR
|
||||||
|
|
||||||
|
PDF 使用 `pypdfium2` 逐页渲染,再将每一页交给 PaddleOCR-VL。默认采用安全的单进程串行模式,页面完成后立即保存,适合 CPU 长时间任务。CPU 默认预留 2 个逻辑核心给系统,可通过 `--threads` 覆盖。
|
||||||
|
|
||||||
|
### CPU 使用
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 处理整个 PDF,默认 DPI 144
|
||||||
|
uv run python pdf_ocr.py documents/sample.pdf
|
||||||
|
|
||||||
|
# 处理指定页:1-5、8、10 到末页
|
||||||
|
uv run python pdf_ocr.py documents/sample.pdf --pages "1-5,8,10-"
|
||||||
|
|
||||||
|
# 中断后继续,已完成页不会重复推理
|
||||||
|
uv run python pdf_ocr.py documents/sample.pdf --resume
|
||||||
|
|
||||||
|
# 删除已有输出并重新处理
|
||||||
|
uv run python pdf_ocr.py documents/sample.pdf --overwrite
|
||||||
|
|
||||||
|
# 保留每页渲染后的 PNG,便于检查输入质量
|
||||||
|
uv run python pdf_ocr.py documents/sample.pdf --keep-rendered
|
||||||
|
|
||||||
|
# 手动设置 CPU 线程数;长任务建议保留 1~2 个核心给系统
|
||||||
|
uv run python pdf_ocr.py documents/sample.pdf --threads 18
|
||||||
|
```
|
||||||
|
|
||||||
|
### GPU 使用
|
||||||
|
|
||||||
|
GPU 入口与 CPU 入口使用相同的 PDF 核心逻辑,但必须在 `gpu/.venv` 和 NVIDIA CUDA GPU 上运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run --project gpu python gpu/pdf_ocr.py documents/sample.pdf \
|
||||||
|
--device-id 0 \
|
||||||
|
--pages "1-10" \
|
||||||
|
--dpi 144
|
||||||
|
```
|
||||||
|
|
||||||
|
当前机器无 NVIDIA 独立显卡,因此 GPU PDF 入口仅完成静态检查,尚未实机验证。
|
||||||
|
|
||||||
|
### 页码语法
|
||||||
|
|
||||||
|
| 参数 | 含义 |
|
||||||
|
|------|------|
|
||||||
|
| `1` | 仅第 1 页 |
|
||||||
|
| `1-5` | 第 1~5 页 |
|
||||||
|
| `10-` | 第 10 页到最后一页 |
|
||||||
|
| `1-5,8,10-` | 多个页码范围组合 |
|
||||||
|
|
||||||
|
用户页码从 1 开始;内部 manifest 使用同样的一基页码记录。
|
||||||
|
|
||||||
|
### 输出结构
|
||||||
|
|
||||||
|
```text
|
||||||
|
outputs/
|
||||||
|
└── sample/
|
||||||
|
├── manifest.json # 任务配置、页状态、耗时和错误
|
||||||
|
├── document.md # 合并后的 Markdown
|
||||||
|
├── document.json # 合并后的 JSON
|
||||||
|
├── pages/
|
||||||
|
│ ├── page-0001.md
|
||||||
|
│ ├── page-0001.json
|
||||||
|
│ └── ...
|
||||||
|
├── assets/ # 表格、图片等 Markdown 资源
|
||||||
|
└── rendered/ # 仅使用 --keep-rendered 时保留
|
||||||
|
```
|
||||||
|
|
||||||
|
默认不保留中间渲染 PNG;OCR 完成后会删除临时图。每页 JSON 会将 `input_path` 恢复为原 PDF 路径,并记录 `page_index`、`page_number`、`page_count` 和 `render_dpi`。
|
||||||
|
|
||||||
|
### 恢复与错误处理
|
||||||
|
|
||||||
|
- 输出目录已存在时,必须显式使用 `--resume` 或 `--overwrite`
|
||||||
|
- `--resume` 会校验 PDF SHA-256 和 DPI,防止接续到错误任务
|
||||||
|
- 单页失败默认写入 manifest 并继续后续页面
|
||||||
|
- `--fail-fast` 可在第一页失败后立即停止
|
||||||
|
- `Ctrl+C` 会保存当前 manifest;下次使用 `--resume` 继续
|
||||||
|
- 逐页文件和 manifest 使用临时文件替换,降低中途退出造成文件损坏的概率
|
||||||
|
|
||||||
|
### DPI 建议
|
||||||
|
|
||||||
|
| 文档类型 | 建议 DPI |
|
||||||
|
|----------|---------:|
|
||||||
|
| 普通打印文字 | 120~144 |
|
||||||
|
| 小字号文档 | 150~200 |
|
||||||
|
| 手写或低质量扫描件 | 200~250 |
|
||||||
|
|
||||||
|
CPU 当前单图实测约 162 秒。长 PDF 总时间可粗略按 `待处理页数 × 单页耗时` 估算,因此建议先用 `--pages "1"` 测试效果和耗时,再扩大页码范围。DPI 越高通常越慢,不建议默认使用 300 DPI。
|
||||||
|
|
||||||
## 工作原理
|
## 工作原理
|
||||||
|
|
||||||
`PaddleOCRVL` pipeline 分两阶段:
|
`PaddleOCRVL` pipeline 分两阶段:
|
||||||
|
|
|
||||||
|
|
@ -96,9 +96,35 @@ Benchmark 会记录:
|
||||||
benchmarks/gpu/gpu-benchmark-YYYYMMDD-HHMMSS.json
|
benchmarks/gpu/gpu-benchmark-YYYYMMDD-HHMMSS.json
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## PDF OCR
|
||||||
|
|
||||||
|
GPU PDF 入口复用仓库根目录 `pdf_ocr_core.py`,按页渲染、逐页保存并支持断点续传:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run --project gpu python gpu/pdf_ocr.py documents/sample.pdf \
|
||||||
|
--device-id 0 \
|
||||||
|
--pages "1-10" \
|
||||||
|
--dpi 144
|
||||||
|
```
|
||||||
|
|
||||||
|
常用选项:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 中断后继续
|
||||||
|
uv run --project gpu python gpu/pdf_ocr.py documents/sample.pdf --resume
|
||||||
|
|
||||||
|
# 删除现有输出后重跑
|
||||||
|
uv run --project gpu python gpu/pdf_ocr.py documents/sample.pdf --overwrite
|
||||||
|
|
||||||
|
# 保留 PDF 页面的渲染 PNG
|
||||||
|
uv run --project gpu python gpu/pdf_ocr.py documents/sample.pdf --keep-rendered
|
||||||
|
```
|
||||||
|
|
||||||
|
无 CUDA 时脚本会立即退出,不会自动回落到 CPU。当前开发机器没有 NVIDIA GPU,因此此入口尚未完成 GPU 实机验证。
|
||||||
|
|
||||||
## 当前范围
|
## 当前范围
|
||||||
|
|
||||||
当前只实现单 GPU、单图 Benchmark。暂未实现 GPU 多进程批处理,原因是:
|
当前实现单 GPU、单图 Benchmark 和单 GPU PDF 逐页 OCR。暂未实现 GPU 多进程批处理,原因是:
|
||||||
|
|
||||||
- 同一 GPU 上启动多个模型实例会重复占用显存
|
- 同一 GPU 上启动多个模型实例会重复占用显存
|
||||||
- 多进程通常不会线性提升单卡吞吐
|
- 多进程通常不会线性提升单卡吞吐
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,153 @@
|
||||||
|
"""GPU entry point for page-by-page PaddleOCR-VL PDF recognition."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import platform
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
GPU_DIR = Path(__file__).resolve().parent
|
||||||
|
PROJECT_ROOT = GPU_DIR.parent
|
||||||
|
if str(PROJECT_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from pdf_ocr_core import preflight_pdf, process_pdf
|
||||||
|
|
||||||
|
DEFAULT_OUTPUT = PROJECT_ROOT / "outputs"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="PaddleOCR-VL-1.6 GPU PDF OCR(逐页、可恢复)",
|
||||||
|
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
||||||
|
)
|
||||||
|
parser.add_argument("pdf", type=Path, help="输入 PDF 文件")
|
||||||
|
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT, help="输出根目录")
|
||||||
|
parser.add_argument("--pages", help="一页或多个页码范围,例如 1-5,8,10-")
|
||||||
|
parser.add_argument("--dpi", type=int, default=144, help="PDF 页面渲染 DPI")
|
||||||
|
parser.add_argument("--password", help="加密 PDF 密码")
|
||||||
|
parser.add_argument("--device-id", type=int, default=0, help="CUDA GPU 编号")
|
||||||
|
parser.add_argument("--resume", action="store_true", help="跳过已完成页,继续现有任务")
|
||||||
|
parser.add_argument("--overwrite", action="store_true", help="删除已有输出并重新处理")
|
||||||
|
parser.add_argument("--keep-rendered", action="store_true", help="保留逐页渲染 PNG")
|
||||||
|
parser.add_argument("--fail-fast", action="store_true", help="任一页失败后立即停止")
|
||||||
|
parser.add_argument("--max-new-tokens", type=int, default=None, help="限制每个文本块最大生成 token")
|
||||||
|
parser.add_argument("--min-pixels", type=int, default=None, help="VLM 最小输入像素参数")
|
||||||
|
parser.add_argument("--max-pixels", type=int, default=None, help="VLM 最大输入像素参数")
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def configure_cuda(device_id: int):
|
||||||
|
try:
|
||||||
|
import paddle
|
||||||
|
except ImportError as exc:
|
||||||
|
raise RuntimeError("未安装 GPU 子项目依赖,请先运行 gpu/setup_env.py。") from exc
|
||||||
|
|
||||||
|
if not paddle.is_compiled_with_cuda():
|
||||||
|
raise RuntimeError("当前 PaddlePaddle 不是 CUDA 构建;本程序不会回退到 CPU。")
|
||||||
|
|
||||||
|
try:
|
||||||
|
device_count = paddle.device.cuda.device_count()
|
||||||
|
except Exception as exc:
|
||||||
|
raise RuntimeError(f"无法查询 CUDA 设备: {exc}") from exc
|
||||||
|
|
||||||
|
if device_count < 1:
|
||||||
|
raise RuntimeError("未检测到 NVIDIA CUDA GPU;本程序不会回退到 CPU。")
|
||||||
|
if device_id < 0 or device_id >= device_count:
|
||||||
|
raise RuntimeError(f"GPU {device_id} 不存在,当前检测到 {device_count} 个设备。")
|
||||||
|
|
||||||
|
device = f"gpu:{device_id}"
|
||||||
|
paddle.set_device(device)
|
||||||
|
paddle.device.cuda.synchronize(device_id)
|
||||||
|
try:
|
||||||
|
name = paddle.device.cuda.get_device_name(device_id)
|
||||||
|
except Exception:
|
||||||
|
name = "unknown"
|
||||||
|
return paddle, device, name
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
try:
|
||||||
|
preflight = preflight_pdf(
|
||||||
|
pdf_path=args.pdf,
|
||||||
|
output_root=args.output,
|
||||||
|
pages=args.pages,
|
||||||
|
dpi=args.dpi,
|
||||||
|
password=args.password,
|
||||||
|
resume=args.resume,
|
||||||
|
overwrite=args.overwrite,
|
||||||
|
)
|
||||||
|
paddle, device, device_name = configure_cuda(args.device_id)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[ERROR] {type(exc).__name__}: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
from paddleocr import PaddleOCRVL
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"[PDF] {preflight['page_count']} pages, selected: "
|
||||||
|
f"{len(preflight['selected_pages'])}, output: {preflight['document_dir']}"
|
||||||
|
)
|
||||||
|
print(f"[GPU] Device: {device} ({device_name})")
|
||||||
|
print("Loading PaddleOCR-VL-1.6...")
|
||||||
|
init_started = time.perf_counter()
|
||||||
|
pipeline = PaddleOCRVL(pipeline_version="v1.6", device=device)
|
||||||
|
paddle.device.cuda.synchronize(args.device_id)
|
||||||
|
init_seconds = time.perf_counter() - init_started
|
||||||
|
print(f"Model init: {init_seconds:.2f}s")
|
||||||
|
|
||||||
|
predict_kwargs = {
|
||||||
|
key: value
|
||||||
|
for key, value in {
|
||||||
|
"max_new_tokens": args.max_new_tokens,
|
||||||
|
"min_pixels": args.min_pixels,
|
||||||
|
"max_pixels": args.max_pixels,
|
||||||
|
}.items()
|
||||||
|
if value is not None
|
||||||
|
}
|
||||||
|
metadata = {
|
||||||
|
"device": device,
|
||||||
|
"device_name": device_name,
|
||||||
|
"python_version": platform.python_version(),
|
||||||
|
"platform": platform.platform(),
|
||||||
|
"paddle_version": paddle.__version__,
|
||||||
|
"model_init_seconds": round(init_seconds, 3),
|
||||||
|
"pipeline_version": "v1.6",
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
summary = process_pdf(
|
||||||
|
pipeline=pipeline,
|
||||||
|
pdf_path=args.pdf,
|
||||||
|
output_root=args.output,
|
||||||
|
pages=args.pages,
|
||||||
|
dpi=args.dpi,
|
||||||
|
password=args.password,
|
||||||
|
resume=args.resume,
|
||||||
|
overwrite=args.overwrite,
|
||||||
|
keep_rendered=args.keep_rendered,
|
||||||
|
fail_fast=args.fail_fast,
|
||||||
|
run_metadata=metadata,
|
||||||
|
predict_kwargs=predict_kwargs,
|
||||||
|
synchronize=lambda: paddle.device.cuda.synchronize(args.device_id),
|
||||||
|
)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\n[INTERRUPTED] 已保存当前进度;使用 --resume 继续。", file=sys.stderr)
|
||||||
|
return 130
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[ERROR] {type(exc).__name__}: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print("\n[PDF OCR Summary]")
|
||||||
|
print(f"Status: {summary['status']}")
|
||||||
|
print(f"Completed: {summary['completed_pages']} / {summary['selected_pages']}")
|
||||||
|
print(f"Failed: {summary['failed_pages']}")
|
||||||
|
print(f"Output: {summary['document_dir']}")
|
||||||
|
return 0 if not summary["failed_pages"] else 3
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
|
|
@ -7,6 +7,7 @@ requires-python = ">=3.11,<3.13"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"paddleocr[doc-parser]==3.7.0",
|
"paddleocr[doc-parser]==3.7.0",
|
||||||
"paddlepaddle-gpu==3.2.1",
|
"paddlepaddle-gpu==3.2.1",
|
||||||
|
"pypdfium2>=5.11.0",
|
||||||
"setuptools>=83.0.0",
|
"setuptools>=83.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,127 @@
|
||||||
|
"""CPU entry point for page-by-page PaddleOCR-VL PDF recognition."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pdf_ocr_core import preflight_pdf, process_pdf
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parent
|
||||||
|
DEFAULT_OUTPUT = PROJECT_ROOT / "outputs"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="PaddleOCR-VL-1.6 CPU PDF OCR(逐页、可恢复)",
|
||||||
|
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
||||||
|
)
|
||||||
|
parser.add_argument("pdf", type=Path, help="输入 PDF 文件")
|
||||||
|
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT, help="输出根目录")
|
||||||
|
parser.add_argument("--pages", help="一页或多个页码范围,例如 1-5,8,10-")
|
||||||
|
parser.add_argument("--dpi", type=int, default=144, help="PDF 页面渲染 DPI")
|
||||||
|
parser.add_argument("--password", help="加密 PDF 密码")
|
||||||
|
parser.add_argument("--threads", type=int, default=None, help="Paddle CPU 线程数")
|
||||||
|
parser.add_argument("--resume", action="store_true", help="跳过已完成页,继续现有任务")
|
||||||
|
parser.add_argument("--overwrite", action="store_true", help="删除已有输出并重新处理")
|
||||||
|
parser.add_argument("--keep-rendered", action="store_true", help="保留逐页渲染 PNG")
|
||||||
|
parser.add_argument("--fail-fast", action="store_true", help="任一页失败后立即停止")
|
||||||
|
parser.add_argument("--max-new-tokens", type=int, default=None, help="限制每个文本块最大生成 token")
|
||||||
|
parser.add_argument("--min-pixels", type=int, default=None, help="VLM 最小输入像素参数")
|
||||||
|
parser.add_argument("--max-pixels", type=int, default=None, help="VLM 最大输入像素参数")
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
total_cores = os.cpu_count() or 4
|
||||||
|
safe_default_threads = max(1, total_cores - 2)
|
||||||
|
threads = args.threads or int(os.environ.get("PADDLE_THREADS", safe_default_threads))
|
||||||
|
if threads < 1:
|
||||||
|
print("[ERROR] --threads 必须大于等于 1", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
try:
|
||||||
|
preflight = preflight_pdf(
|
||||||
|
pdf_path=args.pdf,
|
||||||
|
output_root=args.output,
|
||||||
|
pages=args.pages,
|
||||||
|
dpi=args.dpi,
|
||||||
|
password=args.password,
|
||||||
|
resume=args.resume,
|
||||||
|
overwrite=args.overwrite,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[ERROR] {type(exc).__name__}: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"[PDF] {preflight['page_count']} pages, selected: "
|
||||||
|
f"{len(preflight['selected_pages'])}, output: {preflight['document_dir']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
from paddle import core
|
||||||
|
from paddleocr import PaddleOCRVL
|
||||||
|
|
||||||
|
core.set_num_threads(threads)
|
||||||
|
print(f"[CPU] Threads: {threads} / {total_cores} (reserved: {max(0, total_cores - threads)})")
|
||||||
|
print("Loading PaddleOCR-VL-1.6...")
|
||||||
|
init_started = time.perf_counter()
|
||||||
|
pipeline = PaddleOCRVL(pipeline_version="v1.6", device="cpu")
|
||||||
|
init_seconds = time.perf_counter() - init_started
|
||||||
|
print(f"Model init: {init_seconds:.2f}s")
|
||||||
|
|
||||||
|
predict_kwargs = {
|
||||||
|
key: value
|
||||||
|
for key, value in {
|
||||||
|
"max_new_tokens": args.max_new_tokens,
|
||||||
|
"min_pixels": args.min_pixels,
|
||||||
|
"max_pixels": args.max_pixels,
|
||||||
|
}.items()
|
||||||
|
if value is not None
|
||||||
|
}
|
||||||
|
metadata = {
|
||||||
|
"device": "cpu",
|
||||||
|
"cpu_threads": threads,
|
||||||
|
"python_version": platform.python_version(),
|
||||||
|
"platform": platform.platform(),
|
||||||
|
"model_init_seconds": round(init_seconds, 3),
|
||||||
|
"pipeline_version": "v1.6",
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
summary = process_pdf(
|
||||||
|
pipeline=pipeline,
|
||||||
|
pdf_path=args.pdf,
|
||||||
|
output_root=args.output,
|
||||||
|
pages=args.pages,
|
||||||
|
dpi=args.dpi,
|
||||||
|
password=args.password,
|
||||||
|
resume=args.resume,
|
||||||
|
overwrite=args.overwrite,
|
||||||
|
keep_rendered=args.keep_rendered,
|
||||||
|
fail_fast=args.fail_fast,
|
||||||
|
run_metadata=metadata,
|
||||||
|
predict_kwargs=predict_kwargs,
|
||||||
|
)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\n[INTERRUPTED] 已保存当前进度;使用 --resume 继续。", file=sys.stderr)
|
||||||
|
return 130
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[ERROR] {type(exc).__name__}: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print("\n[PDF OCR Summary]")
|
||||||
|
print(f"Status: {summary['status']}")
|
||||||
|
print(f"Completed: {summary['completed_pages']} / {summary['selected_pages']}")
|
||||||
|
print(f"Failed: {summary['failed_pages']}")
|
||||||
|
print(f"Output: {summary['document_dir']}")
|
||||||
|
return 0 if not summary["failed_pages"] else 3
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
|
|
@ -0,0 +1,534 @@
|
||||||
|
"""Shared PDF rendering, OCR orchestration, resume, and export logic."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Callable, Iterable
|
||||||
|
|
||||||
|
import pypdfium2 as pdfium
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
MANIFEST_VERSION = 1
|
||||||
|
PAGE_SPEC_PATTERN = re.compile(r"^(\d+)(?:-(\d*)?)?$")
|
||||||
|
|
||||||
|
|
||||||
|
def now_iso() -> str:
|
||||||
|
return datetime.now().astimezone().isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def atomic_write_text(path: Path, content: str) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
temporary = path.with_name(f".{path.name}.tmp")
|
||||||
|
temporary.write_text(content, encoding="utf-8")
|
||||||
|
temporary.replace(path)
|
||||||
|
|
||||||
|
|
||||||
|
def atomic_write_json(path: Path, data: Any) -> None:
|
||||||
|
atomic_write_text(path, json.dumps(data, ensure_ascii=False, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
def sha256_file(path: Path, chunk_size: int = 1024 * 1024) -> str:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with path.open("rb") as file:
|
||||||
|
while chunk := file.read(chunk_size):
|
||||||
|
digest.update(chunk)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def safe_stem(value: str) -> str:
|
||||||
|
cleaned = re.sub(r"[^\w.-]+", "_", value, flags=re.UNICODE).strip("._")
|
||||||
|
return cleaned or "document"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_page_spec(spec: str | None, page_count: int) -> list[int]:
|
||||||
|
"""Parse one-based ranges such as ``1-5,8,10-`` into zero-based indexes."""
|
||||||
|
if page_count < 1:
|
||||||
|
return []
|
||||||
|
if spec is None or not spec.strip():
|
||||||
|
return list(range(page_count))
|
||||||
|
|
||||||
|
selected: set[int] = set()
|
||||||
|
for raw_part in spec.split(","):
|
||||||
|
part = raw_part.strip()
|
||||||
|
match = PAGE_SPEC_PATTERN.fullmatch(part)
|
||||||
|
if not match:
|
||||||
|
raise ValueError(f"无效页码范围: {part!r},示例: 1-5,8,10-")
|
||||||
|
|
||||||
|
start = int(match.group(1))
|
||||||
|
end_text = match.group(2)
|
||||||
|
if "-" not in part:
|
||||||
|
end = start
|
||||||
|
elif end_text:
|
||||||
|
end = int(end_text)
|
||||||
|
else:
|
||||||
|
end = page_count
|
||||||
|
|
||||||
|
if start < 1 or end < 1:
|
||||||
|
raise ValueError("PDF 页码从 1 开始")
|
||||||
|
if start > end:
|
||||||
|
raise ValueError(f"页码起始值不能大于结束值: {part}")
|
||||||
|
if start > page_count or end > page_count:
|
||||||
|
raise ValueError(f"页码范围 {part} 超出 PDF 总页数 {page_count}")
|
||||||
|
|
||||||
|
selected.update(range(start - 1, end))
|
||||||
|
|
||||||
|
return sorted(selected)
|
||||||
|
|
||||||
|
|
||||||
|
def format_duration(seconds: float | None) -> str:
|
||||||
|
if seconds is None:
|
||||||
|
return "unknown"
|
||||||
|
if seconds < 60:
|
||||||
|
return f"{seconds:.1f}s"
|
||||||
|
return f"{seconds / 60:.1f}min"
|
||||||
|
|
||||||
|
|
||||||
|
def render_page(document: Any, page_index: int, dpi: int) -> Image.Image:
|
||||||
|
page = document.get_page(page_index)
|
||||||
|
bitmap = None
|
||||||
|
try:
|
||||||
|
bitmap = page.render(scale=dpi / 72.0)
|
||||||
|
return bitmap.to_pil().convert("RGB").copy()
|
||||||
|
finally:
|
||||||
|
if bitmap is not None:
|
||||||
|
bitmap.close()
|
||||||
|
page.close()
|
||||||
|
|
||||||
|
|
||||||
|
def save_png_atomic(image: Image.Image, path: Path) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
temporary = path.with_name(f".{path.name}.tmp")
|
||||||
|
image.save(temporary, format="PNG")
|
||||||
|
temporary.replace(path)
|
||||||
|
|
||||||
|
|
||||||
|
def _save_markdown_image(data: Any, path: Path) -> Path:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
temporary = path.with_name(f".{path.name}.tmp")
|
||||||
|
|
||||||
|
if isinstance(data, Image.Image):
|
||||||
|
image = data
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
array = np.asarray(data)
|
||||||
|
if array.ndim == 3 and array.shape[2] == 4:
|
||||||
|
image = Image.fromarray(array.astype("uint8"), mode="RGBA")
|
||||||
|
elif array.ndim in (2, 3):
|
||||||
|
image = Image.fromarray(array.astype("uint8"))
|
||||||
|
else:
|
||||||
|
raise TypeError(f"unsupported image array shape: {array.shape}")
|
||||||
|
except Exception as exc:
|
||||||
|
raise TypeError(f"无法保存 Markdown 图片 {path.name}: {type(data).__name__}") from exc
|
||||||
|
|
||||||
|
image_format = (path.suffix.lstrip(".") or "png").upper()
|
||||||
|
if image_format == "JPG":
|
||||||
|
image_format = "JPEG"
|
||||||
|
if image_format not in {"PNG", "JPEG", "WEBP", "BMP", "TIFF"}:
|
||||||
|
image_format = "PNG"
|
||||||
|
path = path.with_suffix(".png")
|
||||||
|
temporary = path.with_name(f".{path.name}.tmp")
|
||||||
|
image.save(temporary, format=image_format)
|
||||||
|
temporary.replace(path)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _result_markdown(result: Any, document_dir: Path, page_number: int) -> str:
|
||||||
|
markdown_data = result.markdown
|
||||||
|
if "res" in markdown_data and isinstance(markdown_data["res"], dict):
|
||||||
|
markdown_data = markdown_data["res"]
|
||||||
|
|
||||||
|
text = str(markdown_data.get("markdown_texts", ""))
|
||||||
|
markdown_images = markdown_data.get("markdown_images") or {}
|
||||||
|
page_asset_dir = document_dir / "assets" / f"page-{page_number:04d}"
|
||||||
|
if page_asset_dir.exists():
|
||||||
|
shutil.rmtree(page_asset_dir)
|
||||||
|
|
||||||
|
for index, (original_path, image_data) in enumerate(markdown_images.items(), start=1):
|
||||||
|
original = str(original_path).replace("\\", "/")
|
||||||
|
original_name = Path(original).name or f"image-{index:03d}.png"
|
||||||
|
asset_name = f"{index:03d}-{safe_stem(Path(original_name).stem)}{Path(original_name).suffix or '.png'}"
|
||||||
|
target = page_asset_dir / asset_name
|
||||||
|
target = _save_markdown_image(image_data, target)
|
||||||
|
page_relative = Path(os.path.relpath(target, document_dir / "pages")).as_posix()
|
||||||
|
text = text.replace(original, page_relative)
|
||||||
|
text = text.replace(str(original_path), page_relative)
|
||||||
|
|
||||||
|
return text.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _result_json(result: Any) -> dict[str, Any]:
|
||||||
|
data = result.json
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise TypeError(f"OCR JSON 结果类型异常: {type(data).__name__}")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _page_paths(document_dir: Path, page_number: int) -> tuple[Path, Path]:
|
||||||
|
stem = f"page-{page_number:04d}"
|
||||||
|
return document_dir / "pages" / f"{stem}.md", document_dir / "pages" / f"{stem}.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _page_is_complete(document_dir: Path, manifest: dict[str, Any], page_number: int) -> bool:
|
||||||
|
record = manifest.get("pages", {}).get(str(page_number), {})
|
||||||
|
markdown_path, json_path = _page_paths(document_dir, page_number)
|
||||||
|
return record.get("status") == "completed" and markdown_path.is_file() and json_path.is_file()
|
||||||
|
|
||||||
|
|
||||||
|
def rebuild_combined_outputs(document_dir: Path, manifest: dict[str, Any]) -> None:
|
||||||
|
markdown_parts = [f"# {manifest['document_name']}"]
|
||||||
|
page_json_results = []
|
||||||
|
|
||||||
|
for page_number in manifest.get("selected_pages", []):
|
||||||
|
record = manifest.get("pages", {}).get(str(page_number), {})
|
||||||
|
markdown_path, json_path = _page_paths(document_dir, page_number)
|
||||||
|
if record.get("status") == "completed" and markdown_path.is_file() and json_path.is_file():
|
||||||
|
page_text = markdown_path.read_text(encoding="utf-8")
|
||||||
|
page_text = page_text.replace("../assets/", "assets/")
|
||||||
|
markdown_parts.append(f"\n\n---\n\n## Page {page_number}\n\n{page_text.strip()}")
|
||||||
|
page_json_results.append(
|
||||||
|
{
|
||||||
|
"page_number": page_number,
|
||||||
|
"metrics": record,
|
||||||
|
"ocr_result": json.loads(json_path.read_text(encoding="utf-8")),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif record.get("status") == "failed":
|
||||||
|
markdown_parts.append(
|
||||||
|
f"\n\n---\n\n## Page {page_number}\n\n> OCR failed: {record.get('error', 'unknown error')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
atomic_write_text(document_dir / "document.md", "".join(markdown_parts).rstrip() + "\n")
|
||||||
|
atomic_write_json(
|
||||||
|
document_dir / "document.json",
|
||||||
|
{
|
||||||
|
"manifest": manifest,
|
||||||
|
"page_results": page_json_results,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_manifest(
|
||||||
|
*,
|
||||||
|
pdf_path: Path,
|
||||||
|
document_dir: Path,
|
||||||
|
page_count: int,
|
||||||
|
selected_pages: Iterable[int],
|
||||||
|
dpi: int,
|
||||||
|
resume: bool,
|
||||||
|
overwrite: bool,
|
||||||
|
run_metadata: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
manifest_path = document_dir / "manifest.json"
|
||||||
|
pdf_sha256 = sha256_file(pdf_path)
|
||||||
|
selected_one_based = [index + 1 for index in selected_pages]
|
||||||
|
|
||||||
|
if overwrite and document_dir.exists():
|
||||||
|
shutil.rmtree(document_dir)
|
||||||
|
|
||||||
|
if document_dir.exists() and any(document_dir.iterdir()) and not resume:
|
||||||
|
raise FileExistsError(
|
||||||
|
f"输出目录已存在: {document_dir}。请使用 --resume 继续或 --overwrite 重建。"
|
||||||
|
)
|
||||||
|
|
||||||
|
if resume:
|
||||||
|
if not manifest_path.is_file():
|
||||||
|
raise FileNotFoundError(f"无法断点续传,缺少 manifest: {manifest_path}")
|
||||||
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||||
|
if manifest.get("input", {}).get("sha256") != pdf_sha256:
|
||||||
|
raise ValueError("PDF 内容已变化,不能使用现有断点;请使用 --overwrite")
|
||||||
|
if manifest.get("render", {}).get("dpi") != dpi:
|
||||||
|
raise ValueError("DPI 与现有任务不一致;请使用原 DPI 或 --overwrite")
|
||||||
|
manifest["selected_pages"] = sorted(
|
||||||
|
set(manifest.get("selected_pages", [])) | set(selected_one_based)
|
||||||
|
)
|
||||||
|
manifest["run_metadata"] = run_metadata
|
||||||
|
manifest["status"] = "running"
|
||||||
|
manifest["updated_at"] = now_iso()
|
||||||
|
else:
|
||||||
|
document_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
manifest = {
|
||||||
|
"manifest_version": MANIFEST_VERSION,
|
||||||
|
"document_name": pdf_path.stem,
|
||||||
|
"input": {
|
||||||
|
"path": str(pdf_path),
|
||||||
|
"sha256": pdf_sha256,
|
||||||
|
"size_bytes": pdf_path.stat().st_size,
|
||||||
|
},
|
||||||
|
"page_count": page_count,
|
||||||
|
"selected_pages": selected_one_based,
|
||||||
|
"render": {"dpi": dpi, "format": "png"},
|
||||||
|
"run_metadata": run_metadata,
|
||||||
|
"status": "running",
|
||||||
|
"created_at": now_iso(),
|
||||||
|
"updated_at": now_iso(),
|
||||||
|
"pages": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
atomic_write_json(manifest_path, manifest)
|
||||||
|
return manifest
|
||||||
|
|
||||||
|
|
||||||
|
def validate_pdf_request(
|
||||||
|
pdf_path: Path,
|
||||||
|
output_root: Path,
|
||||||
|
*,
|
||||||
|
resume: bool,
|
||||||
|
overwrite: bool,
|
||||||
|
) -> tuple[Path, Path]:
|
||||||
|
"""Validate cheap input/output conditions before loading the large model."""
|
||||||
|
pdf_path = pdf_path.expanduser().resolve()
|
||||||
|
output_root = output_root.expanduser().resolve()
|
||||||
|
if not pdf_path.is_file():
|
||||||
|
raise FileNotFoundError(f"PDF 不存在: {pdf_path}")
|
||||||
|
if pdf_path.suffix.lower() != ".pdf":
|
||||||
|
raise ValueError(f"输入文件不是 PDF: {pdf_path}")
|
||||||
|
if resume and overwrite:
|
||||||
|
raise ValueError("--resume 和 --overwrite 不能同时使用")
|
||||||
|
|
||||||
|
document_dir = output_root / safe_stem(pdf_path.stem)
|
||||||
|
if resume and not (document_dir / "manifest.json").is_file():
|
||||||
|
raise FileNotFoundError(f"无法断点续传,缺少 manifest: {document_dir / 'manifest.json'}")
|
||||||
|
if document_dir.exists() and any(document_dir.iterdir()) and not (resume or overwrite):
|
||||||
|
raise FileExistsError(
|
||||||
|
f"输出目录已存在: {document_dir}。请使用 --resume 继续或 --overwrite 重建。"
|
||||||
|
)
|
||||||
|
return pdf_path, output_root
|
||||||
|
|
||||||
|
|
||||||
|
def preflight_pdf(
|
||||||
|
*,
|
||||||
|
pdf_path: Path,
|
||||||
|
output_root: Path,
|
||||||
|
pages: str | None,
|
||||||
|
dpi: int,
|
||||||
|
password: str | None,
|
||||||
|
resume: bool,
|
||||||
|
overwrite: bool,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Validate PDF access, page ranges, and output state before model loading."""
|
||||||
|
pdf_path, output_root = validate_pdf_request(
|
||||||
|
pdf_path,
|
||||||
|
output_root,
|
||||||
|
resume=resume,
|
||||||
|
overwrite=overwrite,
|
||||||
|
)
|
||||||
|
if dpi < 72 or dpi > 600:
|
||||||
|
raise ValueError("--dpi 必须在 72 到 600 之间")
|
||||||
|
|
||||||
|
document = pdfium.PdfDocument(str(pdf_path), password=password)
|
||||||
|
try:
|
||||||
|
page_count = len(document)
|
||||||
|
selected = parse_page_spec(pages, page_count)
|
||||||
|
finally:
|
||||||
|
document.close()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"pdf_path": pdf_path,
|
||||||
|
"output_root": output_root,
|
||||||
|
"document_dir": output_root / safe_stem(pdf_path.stem),
|
||||||
|
"page_count": page_count,
|
||||||
|
"selected_pages": [index + 1 for index in selected],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def process_pdf(
|
||||||
|
*,
|
||||||
|
pipeline: Any,
|
||||||
|
pdf_path: Path,
|
||||||
|
output_root: Path,
|
||||||
|
pages: str | None = None,
|
||||||
|
dpi: int = 144,
|
||||||
|
password: str | None = None,
|
||||||
|
resume: bool = False,
|
||||||
|
overwrite: bool = False,
|
||||||
|
keep_rendered: bool = False,
|
||||||
|
fail_fast: bool = False,
|
||||||
|
run_metadata: dict[str, Any] | None = None,
|
||||||
|
predict_kwargs: dict[str, Any] | None = None,
|
||||||
|
synchronize: Callable[[], None] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Render and OCR a PDF one page at a time."""
|
||||||
|
pdf_path, output_root = validate_pdf_request(
|
||||||
|
pdf_path,
|
||||||
|
output_root,
|
||||||
|
resume=resume,
|
||||||
|
overwrite=overwrite,
|
||||||
|
)
|
||||||
|
if dpi < 72 or dpi > 600:
|
||||||
|
raise ValueError("--dpi 必须在 72 到 600 之间")
|
||||||
|
|
||||||
|
predict_kwargs = predict_kwargs or {}
|
||||||
|
run_metadata = run_metadata or {}
|
||||||
|
document_dir = output_root / safe_stem(pdf_path.stem)
|
||||||
|
manifest_path = document_dir / "manifest.json"
|
||||||
|
temporary_render_dir = document_dir / ".render-cache"
|
||||||
|
|
||||||
|
document = pdfium.PdfDocument(str(pdf_path), password=password)
|
||||||
|
try:
|
||||||
|
page_count = len(document)
|
||||||
|
selected_indexes = parse_page_spec(pages, page_count)
|
||||||
|
manifest = prepare_manifest(
|
||||||
|
pdf_path=pdf_path,
|
||||||
|
document_dir=document_dir,
|
||||||
|
page_count=page_count,
|
||||||
|
selected_pages=selected_indexes,
|
||||||
|
dpi=dpi,
|
||||||
|
resume=resume,
|
||||||
|
overwrite=overwrite,
|
||||||
|
run_metadata=run_metadata,
|
||||||
|
)
|
||||||
|
# Resume uses the union stored in the manifest, so newly added ranges and
|
||||||
|
# previously selected pages remain one coherent document task.
|
||||||
|
selected_indexes = [page_number - 1 for page_number in manifest["selected_pages"]]
|
||||||
|
|
||||||
|
completed_before = sum(
|
||||||
|
_page_is_complete(document_dir, manifest, index + 1) for index in selected_indexes
|
||||||
|
)
|
||||||
|
pending_indexes = [
|
||||||
|
index
|
||||||
|
for index in selected_indexes
|
||||||
|
if not _page_is_complete(document_dir, manifest, index + 1)
|
||||||
|
]
|
||||||
|
print(f"PDF: {pdf_path}")
|
||||||
|
print(f"Pages: {page_count}, selected: {len(selected_indexes)}, pending: {len(pending_indexes)}")
|
||||||
|
print(f"Output: {document_dir}")
|
||||||
|
|
||||||
|
run_page_times: list[float] = []
|
||||||
|
for position, page_index in enumerate(pending_indexes, start=1):
|
||||||
|
page_number = page_index + 1
|
||||||
|
page_started = time.perf_counter()
|
||||||
|
render_seconds = 0.0
|
||||||
|
ocr_seconds = 0.0
|
||||||
|
render_path = temporary_render_dir / f"page-{page_number:04d}.png"
|
||||||
|
if keep_rendered:
|
||||||
|
render_path = document_dir / "rendered" / f"page-{page_number:04d}.png"
|
||||||
|
|
||||||
|
try:
|
||||||
|
render_started = time.perf_counter()
|
||||||
|
image = render_page(document, page_index, dpi)
|
||||||
|
try:
|
||||||
|
save_png_atomic(image, render_path)
|
||||||
|
finally:
|
||||||
|
image.close()
|
||||||
|
render_seconds = time.perf_counter() - render_started
|
||||||
|
|
||||||
|
if synchronize:
|
||||||
|
synchronize()
|
||||||
|
ocr_started = time.perf_counter()
|
||||||
|
result_list = pipeline.predict(str(render_path), **predict_kwargs)
|
||||||
|
if synchronize:
|
||||||
|
synchronize()
|
||||||
|
ocr_seconds = time.perf_counter() - ocr_started
|
||||||
|
if not result_list:
|
||||||
|
raise RuntimeError("OCR pipeline 未返回结果")
|
||||||
|
|
||||||
|
result = result_list[0]
|
||||||
|
markdown_text = _result_markdown(result, document_dir, page_number)
|
||||||
|
result_json = _result_json(result)
|
||||||
|
json_payload = result_json.get("res", result_json)
|
||||||
|
if isinstance(json_payload, dict):
|
||||||
|
json_payload["input_path"] = str(pdf_path)
|
||||||
|
json_payload["page_index"] = page_index
|
||||||
|
json_payload["page_number"] = page_number
|
||||||
|
json_payload["page_count"] = page_count
|
||||||
|
json_payload["render_dpi"] = dpi
|
||||||
|
markdown_path, json_path = _page_paths(document_dir, page_number)
|
||||||
|
atomic_write_text(markdown_path, markdown_text.rstrip() + "\n")
|
||||||
|
atomic_write_json(json_path, result_json)
|
||||||
|
|
||||||
|
total_seconds = time.perf_counter() - page_started
|
||||||
|
manifest["pages"][str(page_number)] = {
|
||||||
|
"status": "completed",
|
||||||
|
"page_number": page_number,
|
||||||
|
"render_seconds": round(render_seconds, 3),
|
||||||
|
"ocr_seconds": round(ocr_seconds, 3),
|
||||||
|
"total_seconds": round(total_seconds, 3),
|
||||||
|
"width": result.get("width"),
|
||||||
|
"height": result.get("height"),
|
||||||
|
"layout_boxes": len(result.get("layout_det_res", {}).get("boxes", [])),
|
||||||
|
"parsed_blocks": len(result.get("parsing_res_list", [])),
|
||||||
|
"device": run_metadata.get("device"),
|
||||||
|
"completed_at": now_iso(),
|
||||||
|
}
|
||||||
|
run_page_times.append(total_seconds)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
manifest["status"] = "interrupted"
|
||||||
|
manifest["updated_at"] = now_iso()
|
||||||
|
atomic_write_json(manifest_path, manifest)
|
||||||
|
rebuild_combined_outputs(document_dir, manifest)
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
total_seconds = time.perf_counter() - page_started
|
||||||
|
manifest["pages"][str(page_number)] = {
|
||||||
|
"status": "failed",
|
||||||
|
"page_number": page_number,
|
||||||
|
"render_seconds": round(render_seconds, 3),
|
||||||
|
"ocr_seconds": round(ocr_seconds, 3),
|
||||||
|
"total_seconds": round(total_seconds, 3),
|
||||||
|
"error": f"{type(exc).__name__}: {exc}",
|
||||||
|
"failed_at": now_iso(),
|
||||||
|
}
|
||||||
|
print(f"[FAILED] Page {page_number}: {exc}")
|
||||||
|
if fail_fast:
|
||||||
|
manifest["status"] = "failed"
|
||||||
|
manifest["updated_at"] = now_iso()
|
||||||
|
atomic_write_json(manifest_path, manifest)
|
||||||
|
rebuild_combined_outputs(document_dir, manifest)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
if not keep_rendered and render_path.is_file():
|
||||||
|
render_path.unlink()
|
||||||
|
|
||||||
|
manifest["updated_at"] = now_iso()
|
||||||
|
atomic_write_json(manifest_path, manifest)
|
||||||
|
rebuild_combined_outputs(document_dir, manifest)
|
||||||
|
|
||||||
|
processed_now = position
|
||||||
|
average = sum(run_page_times) / len(run_page_times) if run_page_times else None
|
||||||
|
remaining = len(pending_indexes) - processed_now
|
||||||
|
eta = average * remaining if average is not None else None
|
||||||
|
record = manifest["pages"][str(page_number)]
|
||||||
|
print(
|
||||||
|
f"[{processed_now}/{len(pending_indexes)}] Page {page_number}: "
|
||||||
|
f"{record['status']}, OCR {format_duration(record.get('ocr_seconds'))}, "
|
||||||
|
f"ETA {format_duration(eta)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if temporary_render_dir.exists():
|
||||||
|
shutil.rmtree(temporary_render_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
selected_records = [
|
||||||
|
manifest.get("pages", {}).get(str(index + 1), {}) for index in selected_indexes
|
||||||
|
]
|
||||||
|
failed_pages = [
|
||||||
|
record.get("page_number") for record in selected_records if record.get("status") == "failed"
|
||||||
|
]
|
||||||
|
completed_pages = sum(record.get("status") == "completed" for record in selected_records)
|
||||||
|
manifest["status"] = "completed_with_errors" if failed_pages else "completed"
|
||||||
|
manifest["summary"] = {
|
||||||
|
"selected_pages": len(selected_indexes),
|
||||||
|
"completed_pages": completed_pages,
|
||||||
|
"completed_before_resume": completed_before,
|
||||||
|
"failed_pages": failed_pages,
|
||||||
|
}
|
||||||
|
manifest["updated_at"] = now_iso()
|
||||||
|
atomic_write_json(manifest_path, manifest)
|
||||||
|
rebuild_combined_outputs(document_dir, manifest)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"document_dir": str(document_dir),
|
||||||
|
"manifest_path": str(manifest_path),
|
||||||
|
"status": manifest["status"],
|
||||||
|
**manifest["summary"],
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
document.close()
|
||||||
|
|
@ -5,7 +5,8 @@ description = "Add your description here"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"paddleocr[doc-parser]>=3.6.0",
|
"paddleocr[doc-parser]==3.7.0",
|
||||||
"paddlepaddle==3.2.1",
|
"paddlepaddle==3.2.1",
|
||||||
|
"pypdfium2>=5.11.0",
|
||||||
"setuptools>=83.0.0",
|
"setuptools>=83.0.0",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
4
uv.lock
4
uv.lock
|
|
@ -1032,13 +1032,15 @@ source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "paddleocr", extra = ["doc-parser"] },
|
{ name = "paddleocr", extra = ["doc-parser"] },
|
||||||
{ name = "paddlepaddle" },
|
{ name = "paddlepaddle" },
|
||||||
|
{ name = "pypdfium2" },
|
||||||
{ name = "setuptools" },
|
{ name = "setuptools" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "paddleocr", extras = ["doc-parser"], specifier = ">=3.6.0" },
|
{ name = "paddleocr", extras = ["doc-parser"], specifier = "==3.7.0" },
|
||||||
{ name = "paddlepaddle", specifier = "==3.2.1" },
|
{ name = "paddlepaddle", specifier = "==3.2.1" },
|
||||||
|
{ name = "pypdfium2", specifier = ">=5.11.0" },
|
||||||
{ name = "setuptools", specifier = ">=83.0.0" },
|
{ name = "setuptools", specifier = ">=83.0.0" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue