feat:新增参数矩阵测试程序benchmark.py
This commit is contained in:
parent
d47ca846f5
commit
1cf2c729f3
|
|
@ -15,15 +15,16 @@ gpu/.gpu-ready
|
||||||
# Generated benchmark results
|
# Generated benchmark results
|
||||||
benchmarks/cpu/*.json
|
benchmarks/cpu/*.json
|
||||||
benchmarks/gpu/*.json
|
benchmarks/gpu/*.json
|
||||||
|
benchmarks/runs/
|
||||||
!benchmarks/cpu/.gitkeep
|
!benchmarks/cpu/.gitkeep
|
||||||
!benchmarks/gpu/.gitkeep
|
!benchmarks/gpu/.gitkeep
|
||||||
|
|
||||||
# OCR outputs
|
# OCR outputs
|
||||||
# outputs/
|
outputs/
|
||||||
|
|
||||||
# Generated structured logs (legacy logs directly under logs/ remain tracked)
|
# Generated structured logs (legacy logs directly under logs/ remain tracked)
|
||||||
# logs/input/
|
logs/input/
|
||||||
# logs/verify/
|
logs/verify/
|
||||||
# logs/image/
|
logs/image/
|
||||||
# logs/batch/
|
logs/batch/
|
||||||
# logs/pdf/
|
logs/pdf/
|
||||||
|
|
|
||||||
126
README.md
126
README.md
|
|
@ -11,12 +11,14 @@ PDF 默认使用 **文本提取 + OCR 混合模式**:优先提取 PDF 原始
|
||||||
```text
|
```text
|
||||||
ocr-VL1.6/
|
ocr-VL1.6/
|
||||||
├── ocr.py # 唯一用户入口,自动选择 CPU/GPU 子环境
|
├── ocr.py # 唯一用户入口,自动选择 CPU/GPU 子环境
|
||||||
|
├── benchmark.py # data 参数矩阵测试与 Markdown 报告
|
||||||
├── ocr_app/ # CPU/GPU 共享业务代码
|
├── ocr_app/ # CPU/GPU 共享业务代码
|
||||||
│ ├── cli.py # image/batch/pdf/verify 命令
|
│ ├── cli.py # image/batch/pdf/verify 命令
|
||||||
│ ├── commands.py # 命令实现
|
│ ├── commands.py # 命令实现
|
||||||
│ ├── runtime.py # 设备验证、模型延迟加载
|
│ ├── runtime.py # 设备验证、模型延迟加载
|
||||||
│ ├── pdf.py # 混合 PDF、断点续传、导出
|
│ ├── pdf.py # 混合 PDF、断点续传、导出
|
||||||
│ ├── pdf_text.py # 文本层提取与质量评估
|
│ ├── pdf_text.py # 文本层提取与质量评估
|
||||||
|
│ ├── output.py # 图片/PDF/批次统一输出路径和保存
|
||||||
│ └── logging_utils.py # UTF-8 结构化日志
|
│ └── logging_utils.py # UTF-8 结构化日志
|
||||||
├── cpu/
|
├── cpu/
|
||||||
│ ├── pyproject.toml # CPU 独立依赖
|
│ ├── pyproject.toml # CPU 独立依赖
|
||||||
|
|
@ -388,7 +390,127 @@ logs/legacy/
|
||||||
|
|
||||||
日志使用 UTF-8。Windows 控制台即使显示乱码,日志文件中的中文仍正常。
|
日志使用 UTF-8。Windows 控制台即使显示乱码,日志文件中的中文仍正常。
|
||||||
|
|
||||||
## 测试
|
## 参数矩阵性能测试
|
||||||
|
|
||||||
|
新增 `benchmark.py`,会对 `data/` 中的图片和 PDF 按多组参数运行,统计速度并增量生成 Markdown 报告。
|
||||||
|
|
||||||
|
也可通过统一入口调用:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python ocr.py benchmark --profile smoke --device cpu
|
||||||
|
```
|
||||||
|
|
||||||
|
默认报告:
|
||||||
|
|
||||||
|
```text
|
||||||
|
benchmarks/parameter-comparison.md
|
||||||
|
```
|
||||||
|
|
||||||
|
每个用例的日志、输出和批次 manifest 保存在:
|
||||||
|
|
||||||
|
```text
|
||||||
|
benchmarks/runs/<运行时间>/<case-id>/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 预设矩阵
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 较快:图片 token=64/128、PDF hybrid/text
|
||||||
|
python ocr.py benchmark --profile smoke --device cpu
|
||||||
|
|
||||||
|
# 常用对比:增加 CPU 线程、默认 token、PDF 阈值和 OCR DPI
|
||||||
|
python ocr.py benchmark --profile standard --device cpu
|
||||||
|
|
||||||
|
# 完整矩阵:更多线程、token 和 DPI,耗时最长
|
||||||
|
python ocr.py benchmark --profile full --device cpu
|
||||||
|
```
|
||||||
|
|
||||||
|
真实 CPU OCR 很慢,`standard/full` 可能运行数小时。建议先运行 `smoke`,或通过 `--case` 单独运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python ocr.py benchmark \
|
||||||
|
--profile standard \
|
||||||
|
--case image-threads-8 \
|
||||||
|
--case image-threads-safe \
|
||||||
|
--device cpu
|
||||||
|
```
|
||||||
|
|
||||||
|
查看用例列表而不执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python ocr.py benchmark --profile standard --list-cases
|
||||||
|
```
|
||||||
|
|
||||||
|
仅生成计划和空报告:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python ocr.py benchmark --profile standard --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
### 自定义参数矩阵
|
||||||
|
|
||||||
|
示例配置:
|
||||||
|
|
||||||
|
```text
|
||||||
|
benchmarks/parameter-matrix.example.json
|
||||||
|
```
|
||||||
|
|
||||||
|
运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python ocr.py benchmark \
|
||||||
|
--config benchmarks/parameter-matrix.example.json \
|
||||||
|
--device cpu \
|
||||||
|
--report benchmarks/custom-comparison.md
|
||||||
|
```
|
||||||
|
|
||||||
|
每个 case 支持统一入口中的参数,例如:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "image-threads-8",
|
||||||
|
"title": "图片 8 线程",
|
||||||
|
"group": "image-threads",
|
||||||
|
"kind": "image",
|
||||||
|
"params": {
|
||||||
|
"threads": 8,
|
||||||
|
"max_new_tokens": 64,
|
||||||
|
"rounds": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Markdown 报告统计
|
||||||
|
|
||||||
|
报告按参数组比较:
|
||||||
|
|
||||||
|
- 系统、CPU、逻辑核心和内存信息
|
||||||
|
- 测试文件清单、类型和大小
|
||||||
|
- Wall time
|
||||||
|
- 模型初始化耗时
|
||||||
|
- 图片核心推理 / PDF OCR 总耗时
|
||||||
|
- 平均每文件耗时
|
||||||
|
- 每分钟处理文件数
|
||||||
|
- 相对基准加速比
|
||||||
|
- 输出文本字符数
|
||||||
|
- 文本 SHA-256 是否一致
|
||||||
|
- PDF Text/OCR 页数
|
||||||
|
- PDF 路由原因统计
|
||||||
|
- 完整命令、日志和输出路径
|
||||||
|
|
||||||
|
报告每完成一个 case 就原子更新;按 `Ctrl+C` 中断时,已经完成的结果不会丢失。
|
||||||
|
|
||||||
|
其他参数:
|
||||||
|
|
||||||
|
```text
|
||||||
|
--data PATH 测试数据目录
|
||||||
|
--report PATH Markdown 报告位置
|
||||||
|
--run-root PATH 详细运行数据目录
|
||||||
|
--timeout SEC 单个用例超时
|
||||||
|
--case ID 只执行指定用例,可重复使用
|
||||||
|
```
|
||||||
|
|
||||||
|
## 自动化测试
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv run --project cpu pytest -q
|
uv run --project cpu pytest -q
|
||||||
|
|
@ -405,7 +527,7 @@ uv run --project cpu pytest -q
|
||||||
当前结果:
|
当前结果:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
17 passed
|
21 passed
|
||||||
```
|
```
|
||||||
|
|
||||||
## 已验证状态
|
## 已验证状态
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,711 @@
|
||||||
|
"""Run OCR parameter matrices against data/ and generate an incremental Markdown report."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
import re
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from dataclasses import asdict, dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent
|
||||||
|
DEFAULT_DATA = ROOT / "data"
|
||||||
|
DEFAULT_REPORT = ROOT / "benchmarks" / "parameter-comparison.md"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BenchmarkCase:
|
||||||
|
id: str
|
||||||
|
title: str
|
||||||
|
group: str
|
||||||
|
kind: str
|
||||||
|
params: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CaseResult:
|
||||||
|
case: BenchmarkCase
|
||||||
|
status: str
|
||||||
|
exit_code: int | None
|
||||||
|
command: list[str]
|
||||||
|
started_at: str
|
||||||
|
wall_seconds: float
|
||||||
|
output_dir: str
|
||||||
|
log_file: str
|
||||||
|
batch_manifest: str | None = None
|
||||||
|
discovered_files: int = 0
|
||||||
|
completed_files: int = 0
|
||||||
|
failed_files: int = 0
|
||||||
|
model_init_seconds: float = 0.0
|
||||||
|
program_total_seconds: float = 0.0
|
||||||
|
total_file_seconds: float = 0.0
|
||||||
|
core_processing_seconds: float = 0.0
|
||||||
|
average_file_seconds: float = 0.0
|
||||||
|
throughput_files_per_minute: float = 0.0
|
||||||
|
recognized_chars: int = 0
|
||||||
|
result_hash: str | None = None
|
||||||
|
text_pages: int = 0
|
||||||
|
ocr_pages: int = 0
|
||||||
|
routing_reasons: dict[str, int] = field(default_factory=dict)
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def safe_name(value: str) -> str:
|
||||||
|
cleaned = re.sub(r"[^\w.-]+", "_", value, flags=re.UNICODE).strip("._")
|
||||||
|
return cleaned or "case"
|
||||||
|
|
||||||
|
|
||||||
|
def profile_cases(profile: str, device: str) -> list[BenchmarkCase]:
|
||||||
|
safe_threads = max(1, (os.cpu_count() or 4) - 2)
|
||||||
|
if device == "gpu":
|
||||||
|
smoke = [
|
||||||
|
BenchmarkCase("image-baseline", "图片基准", "image-rounds", "image", {"rounds": 1}),
|
||||||
|
BenchmarkCase("image-token-64", "图片 max_new_tokens=64", "image-tokens", "image", {"max_new_tokens": 64}),
|
||||||
|
BenchmarkCase("image-token-128", "图片 max_new_tokens=128", "image-tokens", "image", {"max_new_tokens": 128}),
|
||||||
|
BenchmarkCase("pdf-hybrid", "PDF 混合模式", "pdf-mode", "pdf", {"pdf_mode": "hybrid"}),
|
||||||
|
BenchmarkCase("pdf-text", "PDF 文本模式", "pdf-mode", "pdf", {"pdf_mode": "text"}),
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
smoke = [
|
||||||
|
BenchmarkCase("image-token-64", "图片 max_new_tokens=64", "image-tokens", "image", {"threads": safe_threads, "max_new_tokens": 64}),
|
||||||
|
BenchmarkCase("image-token-128", "图片 max_new_tokens=128", "image-tokens", "image", {"threads": safe_threads, "max_new_tokens": 128}),
|
||||||
|
BenchmarkCase("pdf-hybrid", "PDF 混合模式", "pdf-mode", "pdf", {"pdf_mode": "hybrid"}),
|
||||||
|
BenchmarkCase("pdf-text", "PDF 文本模式", "pdf-mode", "pdf", {"pdf_mode": "text"}),
|
||||||
|
]
|
||||||
|
|
||||||
|
if profile == "smoke":
|
||||||
|
return smoke
|
||||||
|
|
||||||
|
standard = list(smoke)
|
||||||
|
if device == "cpu":
|
||||||
|
standard.extend(
|
||||||
|
[
|
||||||
|
BenchmarkCase("image-threads-1", "图片 1 线程(token=64)", "image-threads", "image", {"threads": 1, "max_new_tokens": 64}),
|
||||||
|
BenchmarkCase("image-threads-8", "图片 8 线程(token=64)", "image-threads", "image", {"threads": 8, "max_new_tokens": 64}),
|
||||||
|
BenchmarkCase("image-threads-safe", f"图片 {safe_threads} 线程(token=64)", "image-threads", "image", {"threads": safe_threads, "max_new_tokens": 64}),
|
||||||
|
BenchmarkCase("image-token-default", "图片默认 token", "image-tokens", "image", {"threads": safe_threads}),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
standard.extend(
|
||||||
|
[
|
||||||
|
BenchmarkCase("pdf-hybrid-threshold-default", "PDF 混合默认阈值", "pdf-threshold", "pdf", {"pdf_mode": "hybrid"}),
|
||||||
|
BenchmarkCase("pdf-hybrid-minchars-20", "PDF 混合 min_chars=20", "pdf-threshold", "pdf", {"pdf_mode": "hybrid", "text_min_chars": 20}),
|
||||||
|
BenchmarkCase("pdf-hybrid-minchars-100", "PDF 混合 min_chars=100", "pdf-threshold", "pdf", {"pdf_mode": "hybrid", "text_min_chars": 100}),
|
||||||
|
BenchmarkCase("pdf-ocr-dpi-72", "PDF 强制 OCR DPI 72(每份第 1 页)", "pdf-ocr-dpi", "pdf", {"pdf_mode": "ocr", "pages": "1", "dpi": 72, "max_new_tokens": 64}),
|
||||||
|
BenchmarkCase("pdf-ocr-dpi-144", "PDF 强制 OCR DPI 144(每份第 1 页)", "pdf-ocr-dpi", "pdf", {"pdf_mode": "ocr", "pages": "1", "dpi": 144, "max_new_tokens": 64}),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
if profile == "standard":
|
||||||
|
return _deduplicate_cases(standard)
|
||||||
|
|
||||||
|
full = list(standard)
|
||||||
|
if device == "cpu":
|
||||||
|
for threads in (2, 4, 12, 16):
|
||||||
|
full.append(
|
||||||
|
BenchmarkCase(
|
||||||
|
f"image-threads-{threads}",
|
||||||
|
f"图片 {threads} 线程",
|
||||||
|
"image-threads",
|
||||||
|
"image",
|
||||||
|
{"threads": threads, "max_new_tokens": 64},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for dpi in (120, 200):
|
||||||
|
full.append(
|
||||||
|
BenchmarkCase(
|
||||||
|
f"pdf-ocr-dpi-{dpi}",
|
||||||
|
f"PDF 强制 OCR DPI {dpi}(每份第 1 页)",
|
||||||
|
"pdf-ocr-dpi",
|
||||||
|
"pdf",
|
||||||
|
{"pdf_mode": "ocr", "pages": "1", "dpi": dpi, "max_new_tokens": 64},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for max_tokens in (32, 256):
|
||||||
|
full.append(
|
||||||
|
BenchmarkCase(
|
||||||
|
f"image-token-{max_tokens}",
|
||||||
|
f"图片 max_new_tokens={max_tokens}",
|
||||||
|
"image-tokens",
|
||||||
|
"image",
|
||||||
|
{"max_new_tokens": max_tokens, **({"threads": safe_threads} if device == "cpu" else {})},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return _deduplicate_cases(full)
|
||||||
|
|
||||||
|
|
||||||
|
def _deduplicate_cases(cases: list[BenchmarkCase]) -> list[BenchmarkCase]:
|
||||||
|
seen: set[str] = set()
|
||||||
|
result = []
|
||||||
|
for case in cases:
|
||||||
|
if case.id not in seen:
|
||||||
|
seen.add(case.id)
|
||||||
|
result.append(case)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(path: Path) -> list[BenchmarkCase]:
|
||||||
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
raw_cases = data.get("cases", data) if isinstance(data, dict) else data
|
||||||
|
if not isinstance(raw_cases, list):
|
||||||
|
raise ValueError("配置必须是 case 数组,或包含 cases 数组的对象")
|
||||||
|
cases = []
|
||||||
|
for item in raw_cases:
|
||||||
|
case = BenchmarkCase(
|
||||||
|
id=str(item["id"]),
|
||||||
|
title=str(item.get("title", item["id"])),
|
||||||
|
group=str(item.get("group", "custom")),
|
||||||
|
kind=str(item["kind"]),
|
||||||
|
params=dict(item.get("params", {})),
|
||||||
|
)
|
||||||
|
if case.kind not in {"image", "pdf"}:
|
||||||
|
raise ValueError(f"case {case.id} 的 kind 必须是 image/pdf")
|
||||||
|
cases.append(case)
|
||||||
|
ids = [case.id for case in cases]
|
||||||
|
if len(ids) != len(set(ids)):
|
||||||
|
raise ValueError("case id 必须唯一")
|
||||||
|
return cases
|
||||||
|
|
||||||
|
|
||||||
|
def param_to_cli(key: str, value: Any) -> list[str]:
|
||||||
|
option = "--" + key.replace("_", "-")
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return [option] if value else []
|
||||||
|
if value is None:
|
||||||
|
return []
|
||||||
|
return [option, str(value)]
|
||||||
|
|
||||||
|
|
||||||
|
def build_command(
|
||||||
|
case: BenchmarkCase,
|
||||||
|
*,
|
||||||
|
data_dir: Path,
|
||||||
|
device: str,
|
||||||
|
output_dir: Path,
|
||||||
|
log_file: Path,
|
||||||
|
) -> list[str]:
|
||||||
|
command = [
|
||||||
|
sys.executable,
|
||||||
|
str(ROOT / "ocr.py"),
|
||||||
|
str(data_dir),
|
||||||
|
"--recursive",
|
||||||
|
"--only",
|
||||||
|
case.kind,
|
||||||
|
"--device",
|
||||||
|
device,
|
||||||
|
"--output",
|
||||||
|
str(output_dir),
|
||||||
|
"--log-file",
|
||||||
|
str(log_file),
|
||||||
|
"--no-result",
|
||||||
|
"--overwrite",
|
||||||
|
]
|
||||||
|
for key, value in case.params.items():
|
||||||
|
command.extend(param_to_cli(key, value))
|
||||||
|
return command
|
||||||
|
|
||||||
|
|
||||||
|
def file_text_and_hash(paths: list[Path]) -> tuple[int, str | None]:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
character_count = 0
|
||||||
|
has_content = False
|
||||||
|
for path in sorted(paths, key=lambda value: str(value).casefold()):
|
||||||
|
if not path.is_file():
|
||||||
|
continue
|
||||||
|
text = path.read_text(encoding="utf-8", errors="replace")
|
||||||
|
normalized = "\n".join(line.rstrip() for line in text.replace("\r\n", "\n").split("\n")).strip()
|
||||||
|
character_count += len("".join(normalized.split()))
|
||||||
|
digest.update(str(path.name).encode("utf-8"))
|
||||||
|
digest.update(normalized.encode("utf-8"))
|
||||||
|
has_content = True
|
||||||
|
return character_count, digest.hexdigest() if has_content else None
|
||||||
|
|
||||||
|
|
||||||
|
def latest_batch_manifest(output_dir: Path) -> Path | None:
|
||||||
|
files = sorted((output_dir / "batches").glob("*.json"), key=lambda path: path.stat().st_mtime)
|
||||||
|
return files[-1] if files else None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_case_result(
|
||||||
|
case: BenchmarkCase,
|
||||||
|
*,
|
||||||
|
command: list[str],
|
||||||
|
exit_code: int,
|
||||||
|
started_at: str,
|
||||||
|
wall_seconds: float,
|
||||||
|
output_dir: Path,
|
||||||
|
log_file: Path,
|
||||||
|
stderr_tail: str,
|
||||||
|
) -> CaseResult:
|
||||||
|
manifest_path = latest_batch_manifest(output_dir)
|
||||||
|
result = CaseResult(
|
||||||
|
case=case,
|
||||||
|
status="completed" if exit_code == 0 else "failed",
|
||||||
|
exit_code=exit_code,
|
||||||
|
command=command,
|
||||||
|
started_at=started_at,
|
||||||
|
wall_seconds=wall_seconds,
|
||||||
|
output_dir=str(output_dir),
|
||||||
|
log_file=str(log_file),
|
||||||
|
batch_manifest=str(manifest_path) if manifest_path else None,
|
||||||
|
error=stderr_tail or None if exit_code else None,
|
||||||
|
)
|
||||||
|
if manifest_path is None:
|
||||||
|
return result
|
||||||
|
|
||||||
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||||
|
result.discovered_files = int(manifest.get("discovered_files", 0))
|
||||||
|
result.completed_files = int(manifest.get("completed_files", 0))
|
||||||
|
result.failed_files = int(manifest.get("failed_files", 0))
|
||||||
|
result.model_init_seconds = float(manifest.get("model_init_seconds", 0.0))
|
||||||
|
result.program_total_seconds = float(manifest.get("program_total_seconds", 0.0))
|
||||||
|
result.total_file_seconds = float(manifest.get("total_file_seconds", 0.0))
|
||||||
|
result.average_file_seconds = (
|
||||||
|
result.total_file_seconds / result.completed_files if result.completed_files else 0.0
|
||||||
|
)
|
||||||
|
result.throughput_files_per_minute = (
|
||||||
|
result.completed_files * 60.0 / wall_seconds if wall_seconds else 0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
text_paths: list[Path] = []
|
||||||
|
core_seconds = 0.0
|
||||||
|
for item in manifest.get("results", []):
|
||||||
|
outputs = item.get("outputs", {})
|
||||||
|
if item.get("kind") == "image":
|
||||||
|
benchmark_path = Path(outputs.get("benchmark", ""))
|
||||||
|
text_path = Path(outputs.get("text", ""))
|
||||||
|
if benchmark_path.is_file():
|
||||||
|
benchmark = json.loads(benchmark_path.read_text(encoding="utf-8"))
|
||||||
|
core_seconds += sum(benchmark.get("inference_seconds", {}).get("all", []))
|
||||||
|
if text_path.is_file():
|
||||||
|
text_paths.append(text_path)
|
||||||
|
elif item.get("kind") == "pdf":
|
||||||
|
pdf_manifest_path = Path(outputs.get("manifest_path", ""))
|
||||||
|
document_dir = Path(outputs.get("document_dir", ""))
|
||||||
|
if pdf_manifest_path.is_file():
|
||||||
|
pdf_manifest = json.loads(pdf_manifest_path.read_text(encoding="utf-8"))
|
||||||
|
summary = pdf_manifest.get("summary", {})
|
||||||
|
result.text_pages += int(summary.get("text_pages", 0))
|
||||||
|
result.ocr_pages += int(summary.get("ocr_pages", 0))
|
||||||
|
timing = summary.get("timing", {})
|
||||||
|
core_seconds += float(timing.get("ocr_total_seconds", 0.0))
|
||||||
|
for page in pdf_manifest.get("pages", {}).values():
|
||||||
|
reason = page.get("routing_reason", "unknown")
|
||||||
|
result.routing_reasons[reason] = result.routing_reasons.get(reason, 0) + 1
|
||||||
|
document_md = document_dir / "document.md"
|
||||||
|
if document_md.is_file():
|
||||||
|
text_paths.append(document_md)
|
||||||
|
|
||||||
|
result.core_processing_seconds = core_seconds
|
||||||
|
result.recognized_chars, result.result_hash = file_text_and_hash(text_paths)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def format_seconds(value: float) -> str:
|
||||||
|
if value < 60:
|
||||||
|
return f"{value:.2f}s"
|
||||||
|
return f"{value / 60:.2f}min"
|
||||||
|
|
||||||
|
|
||||||
|
def params_text(params: dict[str, Any]) -> str:
|
||||||
|
return ", ".join(f"{key}={value}" for key, value in params.items()) or "默认"
|
||||||
|
|
||||||
|
|
||||||
|
def md_escape(value: Any) -> str:
|
||||||
|
return str(value).replace("|", "\\|").replace("\n", "<br>")
|
||||||
|
|
||||||
|
|
||||||
|
def data_inventory(data_dir: Path) -> list[dict[str, Any]]:
|
||||||
|
supported = {".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff", ".webp", ".pdf"}
|
||||||
|
inventory = []
|
||||||
|
for path in sorted(data_dir.rglob("*"), key=lambda value: str(value).casefold()):
|
||||||
|
if path.is_file() and path.suffix.lower() in supported:
|
||||||
|
inventory.append(
|
||||||
|
{
|
||||||
|
"path": path.relative_to(data_dir).as_posix(),
|
||||||
|
"type": "pdf" if path.suffix.lower() == ".pdf" else "image",
|
||||||
|
"size_bytes": path.stat().st_size,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return inventory
|
||||||
|
|
||||||
|
|
||||||
|
def memory_info() -> tuple[str, str]:
|
||||||
|
try:
|
||||||
|
import psutil
|
||||||
|
|
||||||
|
memory = psutil.virtual_memory()
|
||||||
|
return f"{memory.total / (1024**3):.1f} GB", f"{memory.available / (1024**3):.1f} GB"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if sys.platform == "win32":
|
||||||
|
try:
|
||||||
|
import ctypes
|
||||||
|
|
||||||
|
class MemoryStatus(ctypes.Structure):
|
||||||
|
_fields_ = [
|
||||||
|
("dwLength", ctypes.c_ulong),
|
||||||
|
("dwMemoryLoad", ctypes.c_ulong),
|
||||||
|
("ullTotalPhys", ctypes.c_ulonglong),
|
||||||
|
("ullAvailPhys", ctypes.c_ulonglong),
|
||||||
|
("ullTotalPageFile", ctypes.c_ulonglong),
|
||||||
|
("ullAvailPageFile", ctypes.c_ulonglong),
|
||||||
|
("ullTotalVirtual", ctypes.c_ulonglong),
|
||||||
|
("ullAvailVirtual", ctypes.c_ulonglong),
|
||||||
|
("ullAvailExtendedVirtual", ctypes.c_ulonglong),
|
||||||
|
]
|
||||||
|
|
||||||
|
status = MemoryStatus()
|
||||||
|
status.dwLength = ctypes.sizeof(MemoryStatus)
|
||||||
|
if ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(status)):
|
||||||
|
return (
|
||||||
|
f"{status.ullTotalPhys / (1024**3):.1f} GB",
|
||||||
|
f"{status.ullAvailPhys / (1024**3):.1f} GB",
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return "unknown", "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def render_report(
|
||||||
|
*,
|
||||||
|
report_path: Path,
|
||||||
|
run_id: str,
|
||||||
|
profile: str,
|
||||||
|
device: str,
|
||||||
|
data_dir: Path,
|
||||||
|
cases: list[BenchmarkCase],
|
||||||
|
results: list[CaseResult],
|
||||||
|
run_root: Path,
|
||||||
|
started_at: str,
|
||||||
|
) -> None:
|
||||||
|
by_id = {result.case.id: result for result in results}
|
||||||
|
inventory = data_inventory(data_dir)
|
||||||
|
total_memory, available_memory = memory_info()
|
||||||
|
groups: dict[str, list[BenchmarkCase]] = {}
|
||||||
|
for case in cases:
|
||||||
|
groups.setdefault(case.group, []).append(case)
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
"# PaddleOCR-VL 参数性能对比",
|
||||||
|
"",
|
||||||
|
"## 运行信息",
|
||||||
|
"",
|
||||||
|
f"- 运行 ID:`{run_id}`",
|
||||||
|
f"- 开始时间:`{started_at}`",
|
||||||
|
f"- 更新时间:`{datetime.now().astimezone().isoformat()}`",
|
||||||
|
f"- Profile:`{profile}`",
|
||||||
|
f"- Device:`{device}`",
|
||||||
|
f"- Data:`{data_dir}`",
|
||||||
|
f"- 系统:`{platform.platform()}`",
|
||||||
|
f"- Python:`{platform.python_version()}`",
|
||||||
|
f"- CPU:`{platform.processor() or 'unknown'}`",
|
||||||
|
f"- CPU 逻辑核心:`{os.cpu_count()}`",
|
||||||
|
f"- 内存:总计 `{total_memory}`,当前可用 `{available_memory}`",
|
||||||
|
f"- 测试文件:`{len(inventory)}`(图片 `{sum(item['type'] == 'image' for item in inventory)}`,PDF `{sum(item['type'] == 'pdf' for item in inventory)}`)",
|
||||||
|
f"- 运行目录:`{run_root}`",
|
||||||
|
f"- 完成进度:`{len(results)}/{len(cases)}`",
|
||||||
|
"",
|
||||||
|
"### 测试数据",
|
||||||
|
"",
|
||||||
|
"| 文件 | 类型 | 大小 |",
|
||||||
|
"|---|---:|---:|",
|
||||||
|
*[
|
||||||
|
f"| {md_escape(item['path'])} | {item['type']} | {item['size_bytes'] / 1024:.1f} KiB |"
|
||||||
|
for item in inventory
|
||||||
|
],
|
||||||
|
"",
|
||||||
|
"> 加速比以同组第一个成功用例的 wall time 为基准。结果一致性使用输出文本 SHA-256 比较;仅表示文本完全一致,不代表 OCR 准确率。",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
|
||||||
|
for group, group_cases in groups.items():
|
||||||
|
lines.extend([f"## 参数组:{group}", ""])
|
||||||
|
baseline = next(
|
||||||
|
(by_id[case.id] for case in group_cases if case.id in by_id and by_id[case.id].status == "completed"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
"| 用例 | 参数 | 状态 | 文件 | Wall time | 模型初始化 | 核心推理/OCR | 平均每文件 | 吞吐(files/min) | 相对加速 | 文本字符 | 结果一致 | Text/OCR 页 |",
|
||||||
|
"|---|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
for case in group_cases:
|
||||||
|
result = by_id.get(case.id)
|
||||||
|
if result is None:
|
||||||
|
lines.append(
|
||||||
|
f"| {md_escape(case.title)} | {md_escape(params_text(case.params))} | 待运行 | - | - | - | - | - | - | - | - | - | - |"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
speedup = baseline.wall_seconds / result.wall_seconds if baseline and result.wall_seconds else 0.0
|
||||||
|
consistent = (
|
||||||
|
"基准"
|
||||||
|
if baseline is result
|
||||||
|
else "是"
|
||||||
|
if baseline and baseline.result_hash and baseline.result_hash == result.result_hash
|
||||||
|
else "否"
|
||||||
|
if baseline and baseline.result_hash and result.result_hash
|
||||||
|
else "未知"
|
||||||
|
)
|
||||||
|
lines.append(
|
||||||
|
"| {title} | {params} | {status} | {files} | {wall} | {init} | {core} | {avg} | {throughput:.2f} | {speedup:.2f}x | {chars} | {consistent} | {text}/{ocr} |".format(
|
||||||
|
title=md_escape(case.title),
|
||||||
|
params=md_escape(params_text(case.params)),
|
||||||
|
status=result.status,
|
||||||
|
files=f"{result.completed_files}/{result.discovered_files}",
|
||||||
|
wall=format_seconds(result.wall_seconds),
|
||||||
|
init=format_seconds(result.model_init_seconds),
|
||||||
|
core=format_seconds(result.core_processing_seconds),
|
||||||
|
avg=format_seconds(result.average_file_seconds),
|
||||||
|
throughput=result.throughput_files_per_minute,
|
||||||
|
speedup=speedup,
|
||||||
|
chars=result.recognized_chars,
|
||||||
|
consistent=consistent,
|
||||||
|
text=result.text_pages,
|
||||||
|
ocr=result.ocr_pages,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.extend(["## 用例详情", ""])
|
||||||
|
for case in cases:
|
||||||
|
result = by_id.get(case.id)
|
||||||
|
lines.extend([f"### {case.id} — {case.title}", "", f"- 类型:`{case.kind}`", f"- 参数:`{params_text(case.params)}`"])
|
||||||
|
if result is None:
|
||||||
|
lines.extend(["- 状态:待运行", ""])
|
||||||
|
continue
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
f"- 状态:`{result.status}`",
|
||||||
|
f"- Exit code:`{result.exit_code}`",
|
||||||
|
f"- Wall time:`{format_seconds(result.wall_seconds)}`",
|
||||||
|
f"- 输出目录:`{result.output_dir}`",
|
||||||
|
f"- 日志:`{result.log_file}`",
|
||||||
|
f"- 批次 manifest:`{result.batch_manifest}`",
|
||||||
|
f"- 结果 SHA-256:`{result.result_hash}`",
|
||||||
|
"- 命令:",
|
||||||
|
"",
|
||||||
|
"```text",
|
||||||
|
subprocess.list2cmdline(result.command),
|
||||||
|
"```",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
if result.routing_reasons:
|
||||||
|
lines.append(f"- PDF 路由原因:`{json.dumps(result.routing_reasons, ensure_ascii=False)}`")
|
||||||
|
if result.error:
|
||||||
|
lines.extend(["- 错误摘要:", "", "```text", result.error[-3000:], "```"])
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
report_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
temporary = report_path.with_name(f".{report_path.name}.tmp")
|
||||||
|
temporary.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8")
|
||||||
|
temporary.replace(report_path)
|
||||||
|
|
||||||
|
|
||||||
|
def run_case(
|
||||||
|
case: BenchmarkCase,
|
||||||
|
*,
|
||||||
|
data_dir: Path,
|
||||||
|
device: str,
|
||||||
|
run_root: Path,
|
||||||
|
timeout: float | None,
|
||||||
|
) -> CaseResult:
|
||||||
|
case_root = run_root / safe_name(case.id)
|
||||||
|
output_dir = case_root / "outputs"
|
||||||
|
log_file = case_root / "run.log"
|
||||||
|
console_file = case_root / "console.log"
|
||||||
|
case_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
command = build_command(
|
||||||
|
case,
|
||||||
|
data_dir=data_dir,
|
||||||
|
device=device,
|
||||||
|
output_dir=output_dir,
|
||||||
|
log_file=log_file,
|
||||||
|
)
|
||||||
|
started_at = datetime.now().astimezone().isoformat()
|
||||||
|
started = time.perf_counter()
|
||||||
|
print(f"\n[{case.id}] {case.title}")
|
||||||
|
print(" " + subprocess.list2cmdline(command))
|
||||||
|
environment = os.environ.copy()
|
||||||
|
environment["PYTHONUTF8"] = "1"
|
||||||
|
environment["PYTHONIOENCODING"] = "utf-8"
|
||||||
|
stderr_tail = ""
|
||||||
|
exit_code = -1
|
||||||
|
try:
|
||||||
|
creation_flags = (
|
||||||
|
subprocess.CREATE_NEW_PROCESS_GROUP if sys.platform == "win32" else 0
|
||||||
|
)
|
||||||
|
with console_file.open("w", encoding="utf-8") as console:
|
||||||
|
process = subprocess.Popen(
|
||||||
|
command,
|
||||||
|
cwd=ROOT,
|
||||||
|
stdout=console,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
|
env=environment,
|
||||||
|
creationflags=creation_flags,
|
||||||
|
start_new_session=sys.platform != "win32",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
exit_code = process.wait(timeout=timeout)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
if sys.platform == "win32":
|
||||||
|
subprocess.run(
|
||||||
|
["taskkill", "/PID", str(process.pid), "/T", "/F"],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
os.killpg(process.pid, signal.SIGKILL)
|
||||||
|
process.wait()
|
||||||
|
exit_code = 124
|
||||||
|
stderr_tail = f"case timeout after {timeout}s"
|
||||||
|
console_text = console_file.read_text(encoding="utf-8", errors="replace")
|
||||||
|
console_lines = console_text.splitlines()
|
||||||
|
for line in console_lines[-20:]:
|
||||||
|
print(" " + line)
|
||||||
|
if exit_code != 124:
|
||||||
|
stderr_tail = "\n".join(console_lines[-100:])
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
exit_code = 1
|
||||||
|
stderr_tail = f"{type(exc).__name__}: {exc}"
|
||||||
|
wall_seconds = time.perf_counter() - started
|
||||||
|
return parse_case_result(
|
||||||
|
case,
|
||||||
|
command=command,
|
||||||
|
exit_code=exit_code,
|
||||||
|
started_at=started_at,
|
||||||
|
wall_seconds=wall_seconds,
|
||||||
|
output_dir=output_dir,
|
||||||
|
log_file=log_file,
|
||||||
|
stderr_tail=stderr_tail,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="对 data 下图片/PDF执行参数矩阵测试并生成 Markdown 对比报告",
|
||||||
|
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
||||||
|
)
|
||||||
|
parser.add_argument("--data", type=Path, default=DEFAULT_DATA, help="测试数据目录")
|
||||||
|
parser.add_argument("--device", choices=("cpu", "gpu"), default="cpu", help="测试设备")
|
||||||
|
parser.add_argument("--profile", choices=("smoke", "standard", "full"), default="smoke", help="预设参数矩阵")
|
||||||
|
parser.add_argument("--config", type=Path, default=None, help="自定义 JSON 参数矩阵")
|
||||||
|
parser.add_argument("--report", type=Path, default=DEFAULT_REPORT, help="Markdown 报告路径")
|
||||||
|
parser.add_argument("--run-root", type=Path, default=ROOT / "benchmarks" / "runs", help="详细运行数据根目录")
|
||||||
|
parser.add_argument("--case", action="append", default=[], help="只运行指定 case id,可重复使用")
|
||||||
|
parser.add_argument("--timeout", type=float, default=None, help="每个用例超时秒数")
|
||||||
|
parser.add_argument("--dry-run", action="store_true", help="只生成计划和 Markdown,不执行")
|
||||||
|
parser.add_argument("--list-cases", action="store_true", help="列出用例后退出")
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
data_dir = args.data.expanduser().resolve()
|
||||||
|
if not data_dir.is_dir():
|
||||||
|
print(f"数据目录不存在: {data_dir}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
cases = load_config(args.config.expanduser().resolve()) if args.config else profile_cases(args.profile, args.device)
|
||||||
|
if args.case:
|
||||||
|
requested = set(args.case)
|
||||||
|
cases = [case for case in cases if case.id in requested]
|
||||||
|
missing = requested - {case.id for case in cases}
|
||||||
|
if missing:
|
||||||
|
print(f"未知 case: {sorted(missing)}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
if args.list_cases:
|
||||||
|
for case in cases:
|
||||||
|
print(f"{case.id:30} {case.kind:5} {case.group:18} {params_text(case.params)}")
|
||||||
|
return 0
|
||||||
|
if not cases:
|
||||||
|
print("没有可运行用例", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
run_id = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||||
|
run_root = args.run_root.expanduser().resolve() / run_id
|
||||||
|
report_path = args.report.expanduser().resolve()
|
||||||
|
started_at = datetime.now().astimezone().isoformat()
|
||||||
|
results: list[CaseResult] = []
|
||||||
|
render_report(
|
||||||
|
report_path=report_path,
|
||||||
|
run_id=run_id,
|
||||||
|
profile=args.profile if not args.config else f"config:{args.config}",
|
||||||
|
device=args.device,
|
||||||
|
data_dir=data_dir,
|
||||||
|
cases=cases,
|
||||||
|
results=results,
|
||||||
|
run_root=run_root,
|
||||||
|
started_at=started_at,
|
||||||
|
)
|
||||||
|
print(f"报告: {report_path}")
|
||||||
|
print(f"运行目录: {run_root}")
|
||||||
|
print(f"用例数: {len(cases)}")
|
||||||
|
if args.dry_run:
|
||||||
|
for case in cases:
|
||||||
|
command = build_command(
|
||||||
|
case,
|
||||||
|
data_dir=data_dir,
|
||||||
|
device=args.device,
|
||||||
|
output_dir=run_root / safe_name(case.id) / "outputs",
|
||||||
|
log_file=run_root / safe_name(case.id) / "run.log",
|
||||||
|
)
|
||||||
|
print(f"[{case.id}] {subprocess.list2cmdline(command)}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
interrupted = False
|
||||||
|
try:
|
||||||
|
for index, case in enumerate(cases, 1):
|
||||||
|
print(f"\n=== Case {index}/{len(cases)} ===")
|
||||||
|
result = run_case(
|
||||||
|
case,
|
||||||
|
data_dir=data_dir,
|
||||||
|
device=args.device,
|
||||||
|
run_root=run_root,
|
||||||
|
timeout=args.timeout,
|
||||||
|
)
|
||||||
|
results.append(result)
|
||||||
|
render_report(
|
||||||
|
report_path=report_path,
|
||||||
|
run_id=run_id,
|
||||||
|
profile=args.profile if not args.config else f"config:{args.config}",
|
||||||
|
device=args.device,
|
||||||
|
data_dir=data_dir,
|
||||||
|
cases=cases,
|
||||||
|
results=results,
|
||||||
|
run_root=run_root,
|
||||||
|
started_at=started_at,
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f"完成: status={result.status}, wall={format_seconds(result.wall_seconds)}, "
|
||||||
|
f"files={result.completed_files}/{result.discovered_files}"
|
||||||
|
)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
interrupted = True
|
||||||
|
print("\n测试被中断,已完成结果已写入 Markdown。", file=sys.stderr)
|
||||||
|
|
||||||
|
failed = [result for result in results if result.status != "completed"]
|
||||||
|
print(f"\n报告已更新: {report_path}")
|
||||||
|
print(f"完成 {len(results)}/{len(cases)},失败 {len(failed)}")
|
||||||
|
if interrupted:
|
||||||
|
return 130
|
||||||
|
return 0 if not failed else 3
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
{
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"id": "image-cpu-threads-8",
|
||||||
|
"title": "图片 CPU 8 线程",
|
||||||
|
"group": "image-threads",
|
||||||
|
"kind": "image",
|
||||||
|
"params": {
|
||||||
|
"threads": 8,
|
||||||
|
"max_new_tokens": 64,
|
||||||
|
"rounds": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "image-cpu-threads-18",
|
||||||
|
"title": "图片 CPU 18 线程",
|
||||||
|
"group": "image-threads",
|
||||||
|
"kind": "image",
|
||||||
|
"params": {
|
||||||
|
"threads": 18,
|
||||||
|
"max_new_tokens": 64,
|
||||||
|
"rounds": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "pdf-hybrid-default",
|
||||||
|
"title": "PDF 混合默认阈值",
|
||||||
|
"group": "pdf-routing",
|
||||||
|
"kind": "pdf",
|
||||||
|
"params": {
|
||||||
|
"pdf_mode": "hybrid"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "pdf-hybrid-strict",
|
||||||
|
"title": "PDF 混合严格文本阈值",
|
||||||
|
"group": "pdf-routing",
|
||||||
|
"kind": "pdf",
|
||||||
|
"params": {
|
||||||
|
"pdf_mode": "hybrid",
|
||||||
|
"text_min_chars": 100,
|
||||||
|
"text_min_content_ratio": 0.7
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "pdf-ocr-dpi-144",
|
||||||
|
"title": "PDF OCR DPI 144",
|
||||||
|
"group": "pdf-ocr-dpi",
|
||||||
|
"kind": "pdf",
|
||||||
|
"params": {
|
||||||
|
"pdf_mode": "ocr",
|
||||||
|
"pages": "1",
|
||||||
|
"dpi": 144,
|
||||||
|
"max_new_tokens": 64
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
5
ocr.py
5
ocr.py
|
|
@ -4,6 +4,7 @@ Examples:
|
||||||
python ocr.py data/images/手写01.png --device cpu
|
python ocr.py data/images/手写01.png --device cpu
|
||||||
python ocr.py data/documents/sample.pdf --device cpu --pdf-mode hybrid
|
python ocr.py data/documents/sample.pdf --device cpu --pdf-mode hybrid
|
||||||
python ocr.py data/ --recursive --device cpu
|
python ocr.py data/ --recursive --device cpu
|
||||||
|
python ocr.py benchmark --profile smoke --device cpu
|
||||||
python ocr.py verify --device gpu
|
python ocr.py verify --device gpu
|
||||||
|
|
||||||
The launcher deliberately executes the selected isolated uv project, so CPU
|
The launcher deliberately executes the selected isolated uv project, so CPU
|
||||||
|
|
@ -32,6 +33,10 @@ def _requested_device(argv: list[str]) -> str:
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
|
if len(sys.argv) > 1 and sys.argv[1] == "benchmark":
|
||||||
|
command = [sys.executable, str(ROOT / "benchmark.py"), *sys.argv[2:]]
|
||||||
|
return subprocess.run(command, cwd=ROOT).returncode
|
||||||
|
|
||||||
try:
|
try:
|
||||||
device = _requested_device(sys.argv[1:])
|
device = _requested_device(sys.argv[1:])
|
||||||
except SystemExit as exc:
|
except SystemExit as exc:
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,13 @@ def build_input_parser(device_override: str | None = None) -> argparse.ArgumentP
|
||||||
)
|
)
|
||||||
parser.add_argument("input", type=Path, help="图片、PDF 或目录")
|
parser.add_argument("input", type=Path, help="图片、PDF 或目录")
|
||||||
parser.add_argument("--recursive", action="store_true", help="目录模式递归扫描子目录")
|
parser.add_argument("--recursive", action="store_true", help="目录模式递归扫描子目录")
|
||||||
parser.add_argument("--output", type=Path, default=PROJECT_ROOT / "outputs", help="PDF 输出根目录")
|
parser.add_argument(
|
||||||
|
"--only",
|
||||||
|
choices=("all", "image", "pdf"),
|
||||||
|
default="all",
|
||||||
|
help="目录模式只处理指定类型",
|
||||||
|
)
|
||||||
|
parser.add_argument("--output", type=Path, default=PROJECT_ROOT / "outputs", help="统一输出根目录")
|
||||||
parser.add_argument("--fail-fast", action="store_true", help="单文件失败后立即停止目录任务")
|
parser.add_argument("--fail-fast", action="store_true", help="单文件失败后立即停止目录任务")
|
||||||
|
|
||||||
# Image options
|
# Image options
|
||||||
|
|
|
||||||
|
|
@ -47,13 +47,24 @@ def detect_input_kind(path: Path) -> str:
|
||||||
return "unsupported"
|
return "unsupported"
|
||||||
|
|
||||||
|
|
||||||
def discover_supported_files(directory: Path, *, recursive: bool, output_root: Path) -> list[Path]:
|
def discover_supported_files(
|
||||||
|
directory: Path,
|
||||||
|
*,
|
||||||
|
recursive: bool,
|
||||||
|
output_root: Path,
|
||||||
|
only: str = "all",
|
||||||
|
) -> list[Path]:
|
||||||
iterator = directory.rglob("*") if recursive else directory.glob("*")
|
iterator = directory.rglob("*") if recursive else directory.glob("*")
|
||||||
output_root = output_root.expanduser().resolve()
|
output_root = output_root.expanduser().resolve()
|
||||||
files: list[Path] = []
|
files: list[Path] = []
|
||||||
for path in iterator:
|
for path in iterator:
|
||||||
if not path.is_file() or path.suffix.lower() not in SUPPORTED_EXTENSIONS:
|
if not path.is_file() or path.suffix.lower() not in SUPPORTED_EXTENSIONS:
|
||||||
continue
|
continue
|
||||||
|
suffix = path.suffix.lower()
|
||||||
|
if only == "image" and suffix not in IMAGE_EXTENSIONS:
|
||||||
|
continue
|
||||||
|
if only == "pdf" and suffix not in PDF_EXTENSIONS:
|
||||||
|
continue
|
||||||
resolved = path.resolve()
|
resolved = path.resolve()
|
||||||
try:
|
try:
|
||||||
resolved.relative_to(output_root)
|
resolved.relative_to(output_root)
|
||||||
|
|
@ -375,14 +386,16 @@ def run_input(args, provider: PipelineProvider, logger: logging.Logger, project_
|
||||||
input_path,
|
input_path,
|
||||||
recursive=args.recursive,
|
recursive=args.recursive,
|
||||||
output_root=args.output,
|
output_root=args.output,
|
||||||
|
only=args.only,
|
||||||
)
|
)
|
||||||
if not files:
|
if not files:
|
||||||
logger.error("NO_SUPPORTED_FILES directory=%s recursive=%s", input_path, args.recursive)
|
logger.error("NO_SUPPORTED_FILES directory=%s recursive=%s", input_path, args.recursive)
|
||||||
return 1
|
return 1
|
||||||
logger.info(
|
logger.info(
|
||||||
"DIRECTORY_PLAN directory=%s recursive=%s files=%d image_files=%d pdf_files=%d",
|
"DIRECTORY_PLAN directory=%s recursive=%s only=%s files=%d image_files=%d pdf_files=%d",
|
||||||
input_path,
|
input_path,
|
||||||
args.recursive,
|
args.recursive,
|
||||||
|
args.only,
|
||||||
len(files),
|
len(files),
|
||||||
sum(path.suffix.lower() in IMAGE_EXTENSIONS for path in files),
|
sum(path.suffix.lower() in IMAGE_EXTENSIONS for path in files),
|
||||||
sum(path.suffix.lower() in PDF_EXTENSIONS for path in files),
|
sum(path.suffix.lower() in PDF_EXTENSIONS for path in files),
|
||||||
|
|
@ -430,6 +443,7 @@ def run_input(args, provider: PipelineProvider, logger: logging.Logger, project_
|
||||||
batch_manifest = {
|
batch_manifest = {
|
||||||
"input_directory": str(input_path),
|
"input_directory": str(input_path),
|
||||||
"recursive": args.recursive,
|
"recursive": args.recursive,
|
||||||
|
"only": args.only,
|
||||||
"device": provider.resolved_device,
|
"device": provider.resolved_device,
|
||||||
"discovered_files": len(files),
|
"discovered_files": len(files),
|
||||||
"completed_files": len(results),
|
"completed_files": len(results),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,127 @@
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from benchmark import (
|
||||||
|
BenchmarkCase,
|
||||||
|
CaseResult,
|
||||||
|
load_config,
|
||||||
|
profile_cases,
|
||||||
|
render_report,
|
||||||
|
run_case,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_smoke_profile_has_image_and_pdf_cases():
|
||||||
|
cases = profile_cases("smoke", "cpu")
|
||||||
|
assert {case.kind for case in cases} == {"image", "pdf"}
|
||||||
|
assert len({case.id for case in cases}) == len(cases)
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_custom_config(tmp_path):
|
||||||
|
config = tmp_path / "matrix.json"
|
||||||
|
config.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"id": "custom-image",
|
||||||
|
"title": "Custom",
|
||||||
|
"group": "custom",
|
||||||
|
"kind": "image",
|
||||||
|
"params": {"threads": 4},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
cases = load_config(config)
|
||||||
|
assert cases == [
|
||||||
|
BenchmarkCase(
|
||||||
|
id="custom-image",
|
||||||
|
title="Custom",
|
||||||
|
group="custom",
|
||||||
|
kind="image",
|
||||||
|
params={"threads": 4},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_report_contains_speed_and_hash_comparison(tmp_path):
|
||||||
|
cases = [
|
||||||
|
BenchmarkCase("base", "Base", "image", "image", {"threads": 1}),
|
||||||
|
BenchmarkCase("fast", "Fast", "image", "image", {"threads": 8}),
|
||||||
|
]
|
||||||
|
results = [
|
||||||
|
CaseResult(
|
||||||
|
case=cases[0],
|
||||||
|
status="completed",
|
||||||
|
exit_code=0,
|
||||||
|
command=["python", "ocr.py"],
|
||||||
|
started_at="2026-01-01T00:00:00+08:00",
|
||||||
|
wall_seconds=20.0,
|
||||||
|
output_dir="out/base",
|
||||||
|
log_file="base.log",
|
||||||
|
completed_files=2,
|
||||||
|
discovered_files=2,
|
||||||
|
average_file_seconds=8.0,
|
||||||
|
throughput_files_per_minute=6.0,
|
||||||
|
result_hash="same",
|
||||||
|
recognized_chars=100,
|
||||||
|
),
|
||||||
|
CaseResult(
|
||||||
|
case=cases[1],
|
||||||
|
status="completed",
|
||||||
|
exit_code=0,
|
||||||
|
command=["python", "ocr.py"],
|
||||||
|
started_at="2026-01-01T00:00:00+08:00",
|
||||||
|
wall_seconds=10.0,
|
||||||
|
output_dir="out/fast",
|
||||||
|
log_file="fast.log",
|
||||||
|
completed_files=2,
|
||||||
|
discovered_files=2,
|
||||||
|
average_file_seconds=4.0,
|
||||||
|
throughput_files_per_minute=12.0,
|
||||||
|
result_hash="same",
|
||||||
|
recognized_chars=100,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
report = tmp_path / "report.md"
|
||||||
|
render_report(
|
||||||
|
report_path=report,
|
||||||
|
run_id="test",
|
||||||
|
profile="test",
|
||||||
|
device="cpu",
|
||||||
|
data_dir=Path("data"),
|
||||||
|
cases=cases,
|
||||||
|
results=results,
|
||||||
|
run_root=Path("runs"),
|
||||||
|
started_at="2026-01-01T00:00:00+08:00",
|
||||||
|
)
|
||||||
|
text = report.read_text(encoding="utf-8")
|
||||||
|
assert "2.00x" in text
|
||||||
|
assert "结果一致" in text
|
||||||
|
assert "| 是 |" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_case_timeout_returns_124(tmp_path, monkeypatch):
|
||||||
|
script = tmp_path / "slow.py"
|
||||||
|
script.write_text("import time; time.sleep(10)", encoding="utf-8")
|
||||||
|
case = BenchmarkCase("slow", "Slow", "timeout", "image", {})
|
||||||
|
|
||||||
|
def fake_build_command(*args, **kwargs):
|
||||||
|
import sys
|
||||||
|
|
||||||
|
return [sys.executable, str(script)]
|
||||||
|
|
||||||
|
monkeypatch.setattr("benchmark.build_command", fake_build_command)
|
||||||
|
result = run_case(
|
||||||
|
case,
|
||||||
|
data_dir=tmp_path,
|
||||||
|
device="cpu",
|
||||||
|
run_root=tmp_path / "runs",
|
||||||
|
timeout=0.1,
|
||||||
|
)
|
||||||
|
assert result.exit_code == 124
|
||||||
|
assert result.status == "failed"
|
||||||
|
assert "timeout" in (result.error or "")
|
||||||
Loading…
Reference in New Issue