feat:新增参数矩阵测试程序benchmark.py

This commit is contained in:
kuuhaku 2026-07-17 11:35:47 +08:00
parent d47ca846f5
commit 1cf2c729f3
8 changed files with 1055 additions and 11 deletions

13
.gitignore vendored
View File

@ -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
View File

@ -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
``` ```
## 已验证状态 ## 已验证状态

711
benchmark.py Normal file
View File

@ -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())

View File

@ -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
View File

@ -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:

View File

@ -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

View File

@ -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),

127
tests/test_benchmark.py Normal file
View File

@ -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 "")