"""Run PP-OCRv6 parameter combinations against data/ and write a Markdown report.""" from __future__ import annotations import argparse import itertools import json import shutil import statistics import subprocess import sys import time from dataclasses import asdict, dataclass from datetime import datetime from pathlib import Path from typing import Any ROOT = Path(__file__).resolve().parent DEFAULT_DATA = ROOT / "data" DEFAULT_BENCHMARK_ROOT = ROOT / "benchmarks" / "parameter-runs" MODEL_SIZES = ("tiny", "small", "medium") PROFILE_OPTIONS = { "fast": { "doc_orientation": False, "doc_unwarping": False, "textline_orientation": False, "description": "关闭文档方向、去畸变和文本行方向,优先速度", }, "standard": { "doc_orientation": True, "doc_unwarping": False, "textline_orientation": True, "description": "开启文档方向和文本行方向,关闭去畸变", }, "robust": { "doc_orientation": True, "doc_unwarping": True, "textline_orientation": True, "description": "开启方向处理和文档去畸变,优先复杂输入适应性", }, } @dataclass(frozen=True) class Scenario: model_size: str profile: str dpi: int threads: int | None det_limit_side_len: int = 64 det_limit_type: str = "min" det_thresh: float = 0.3 det_box_thresh: float = 0.6 det_unclip_ratio: float = 1.5 rec_score_thresh: float = 0.0 rec_batch_size: int = 6 @staticmethod def _slug(value: float) -> str: return f"{value:g}".replace("-", "m").replace(".", "p") @property def name(self) -> str: thread_name = "auto" if self.threads is None else str(self.threads) return ( f"{self.model_size}-{self.profile}-dpi{self.dpi}-th{thread_name}" f"-s{self.det_limit_side_len}{self.det_limit_type}" f"-d{self._slug(self.det_thresh)}-b{self._slug(self.det_box_thresh)}" f"-u{self._slug(self.det_unclip_ratio)}-r{self._slug(self.rec_score_thresh)}" f"-rb{self.rec_batch_size}" ) @dataclass class ScenarioResult: scenario: Scenario status: str exit_code: int wall_seconds: float model_init_seconds: float | None pure_ocr_seconds: float image_inference_seconds: float pdf_ocr_seconds: float processed_files: int image_files: int pdf_files: int ocr_pages: int recognized_lines: int mean_confidence: float | None units_per_second: float | None output_dir: str log_file: str command: list[str] error: str | None = None def parse_threads(values: list[str]) -> list[int | None]: parsed: list[int | None] = [] for value in values: normalized = value.strip().lower() if normalized == "auto": item = None else: item = int(normalized) if item < 1: raise ValueError("线程数必须大于等于 1") if item not in parsed: parsed.append(item) return parsed def build_scenarios( model_sizes: list[str], profiles: list[str], dpis: list[int], threads: list[int | None], det_limit_side_lens: list[int] | None = None, det_limit_types: list[str] | None = None, det_thresholds: list[float] | None = None, det_box_thresholds: list[float] | None = None, det_unclip_ratios: list[float] | None = None, rec_score_thresholds: list[float] | None = None, rec_batch_sizes: list[int] | None = None, ) -> list[Scenario]: dimensions = itertools.product( model_sizes, profiles, dpis, threads, det_limit_side_lens or [64], det_limit_types or ["min"], det_thresholds or [0.3], det_box_thresholds or [0.6], det_unclip_ratios or [1.5], rec_score_thresholds or [0.0], rec_batch_sizes or [6], ) return [Scenario(*values) for values in dimensions] def _bool_flag(name: str, enabled: bool) -> str: return f"--{name}" if enabled else f"--no-{name}" def build_command( scenario: Scenario, *, data_dir: Path, output_dir: Path, device: str, device_id: int, warmup: int, rounds: int, pdf_mode: str, ) -> list[str]: profile = PROFILE_OPTIONS[scenario.profile] command = [ sys.executable, str(ROOT / "ocr.py"), str(data_dir), "--recursive", "--device", device, "--device-id", str(device_id), "--model-size", scenario.model_size, "--pdf-mode", pdf_mode, "--dpi", str(scenario.dpi), "--warmup", str(warmup), "--rounds", str(rounds), "--text-det-limit-side-len", str(scenario.det_limit_side_len), "--text-det-limit-type", scenario.det_limit_type, "--text-det-thresh", str(scenario.det_thresh), "--text-det-box-thresh", str(scenario.det_box_thresh), "--text-det-unclip-ratio", str(scenario.det_unclip_ratio), "--text-rec-score-thresh", str(scenario.rec_score_thresh), "--text-recognition-batch-size", str(scenario.rec_batch_size), "--output", str(output_dir), "--overwrite", "--no-result", _bool_flag("doc-orientation-classify", profile["doc_orientation"]), _bool_flag("doc-unwarping", profile["doc_unwarping"]), _bool_flag("textline-orientation", profile["textline_orientation"]), ] if scenario.threads is not None: command.extend(["--threads", str(scenario.threads)]) return command def _read_json(path: Path) -> dict[str, Any]: return json.loads(path.read_text(encoding="utf-8")) def _collect_confidences(payload: dict[str, Any]) -> list[float]: scores: list[float] = [] for line in payload.get("lines", []): value = line.get("score") if isinstance(value, (int, float)): scores.append(float(value)) return scores def collect_scenario_metrics( scenario: Scenario, *, output_dir: Path, log_file: Path, command: list[str], exit_code: int, wall_seconds: float, error: str | None, ) -> ScenarioResult: batch_manifests = sorted((output_dir / "batches").glob("*.json")) batch = _read_json(batch_manifests[-1]) if batch_manifests else {} image_benchmarks = list((output_dir / "images").rglob("benchmark.json")) image_results = list((output_dir / "images").rglob("result.json")) pdf_manifests = list((output_dir / "pdfs").rglob("manifest.json")) pdf_page_results = list((output_dir / "pdfs").rglob("pages/page-*.json")) image_inference = 0.0 model_init_values: list[float] = [] recognized_lines = 0 confidences: list[float] = [] for path in image_benchmarks: payload = _read_json(path) image_inference += sum(float(value) for value in payload.get("inference_seconds", {}).get("all", [])) value = payload.get("model_init_seconds") if isinstance(value, (int, float)): model_init_values.append(float(value)) for path in image_results: payload = _read_json(path) recognized_lines += int(payload.get("summary", {}).get("non_empty_lines", 0)) confidences.extend(_collect_confidences(payload)) pdf_ocr_seconds = 0.0 ocr_pages = 0 for path in pdf_manifests: payload = _read_json(path) summary = payload.get("summary", {}) ocr_pages += int(summary.get("ocr_pages", 0)) metadata = payload.get("run_metadata", {}) value = metadata.get("model_init_seconds") if isinstance(value, (int, float)): model_init_values.append(float(value)) for page in payload.get("pages", {}).values(): pdf_ocr_seconds += float(page.get("ocr_seconds", 0.0) or 0.0) if page.get("source_type") == "text": recognized_lines += int(page.get("detected_lines", 0) or 0) for path in pdf_page_results: payload = _read_json(path) if "lines" in payload: recognized_lines += int(payload.get("summary", {}).get("non_empty_lines", 0)) confidences.extend(_collect_confidences(payload)) pure_ocr_seconds = image_inference + pdf_ocr_seconds image_files = len(image_results) pdf_files = len(pdf_manifests) units = image_files + ocr_pages units_per_second = units / pure_ocr_seconds if pure_ocr_seconds > 0 else None status = "completed" if exit_code == 0 else "failed" if exit_code == 0 and not batch: status = "incomplete" error = error or "未找到批处理 manifest" return ScenarioResult( scenario=scenario, status=status, exit_code=exit_code, wall_seconds=wall_seconds, model_init_seconds=max(model_init_values) if model_init_values else None, pure_ocr_seconds=pure_ocr_seconds, image_inference_seconds=image_inference, pdf_ocr_seconds=pdf_ocr_seconds, processed_files=int(batch.get("completed_files", image_files + pdf_files)), image_files=image_files, pdf_files=pdf_files, ocr_pages=ocr_pages, recognized_lines=recognized_lines, mean_confidence=statistics.fmean(confidences) if confidences else None, units_per_second=units_per_second, output_dir=str(output_dir), log_file=str(log_file), command=command, error=error, ) def _format_seconds(value: float | None) -> str: return "-" if value is None else f"{value:.3f}" def _format_float(value: float | None, digits: int = 4) -> str: return "-" if value is None else f"{value:.{digits}f}" def render_report( results: list[ScenarioResult], *, data_dir: Path, device: str, pdf_mode: str, warmup: int, rounds: int, started_at: datetime, finished_at: datetime, ) -> str: completed = [result for result in results if result.status == "completed"] by_ocr = sorted(completed, key=lambda item: item.pure_ocr_seconds or float("inf")) by_wall = sorted(completed, key=lambda item: item.wall_seconds) lines = [ "# PP-OCRv6 参数测试报告", "", "## 测试信息", "", f"- 数据目录:`{data_dir}`", f"- 运行设备:`{device}`", f"- PDF 模式:`{pdf_mode}`", f"- 图片预热轮数:`{warmup}`", f"- 图片推理轮数:`{rounds}`", f"- 场景数量:`{len(results)}`", f"- 开始时间:`{started_at.astimezone().isoformat()}`", f"- 结束时间:`{finished_at.astimezone().isoformat()}`", f"- 总耗时:`{(finished_at - started_at).total_seconds():.3f}s`", "", "> 纯 OCR 耗时为图片推理耗时与 PDF OCR 页面耗时之和,不含模型初始化、文件扫描、PDF 文本层提取和结果导出。首次下载模型会显著增加墙钟耗时,应优先参考缓存模型后的结果。", "", "## 汇总对比", "", "| 排名 | 场景 | 状态 | 墙钟耗时(s) | 模型初始化(s) | 纯OCR耗时(s) | 图片推理(s) | PDF OCR(s) | OCR单位/秒 | 识别行数 | 平均置信度 |", "|---:|---|---|---:|---:|---:|---:|---:|---:|---:|---:|", ] rank_map = {result.scenario.name: index + 1 for index, result in enumerate(by_ocr)} for result in results: lines.append( "| {rank} | `{name}` | {status} | {wall} | {init} | {pure} | {image} | {pdf} | {rate} | {count} | {confidence} |".format( rank=rank_map.get(result.scenario.name, "-"), name=result.scenario.name, status=result.status, wall=_format_seconds(result.wall_seconds), init=_format_seconds(result.model_init_seconds), pure=_format_seconds(result.pure_ocr_seconds), image=_format_seconds(result.image_inference_seconds), pdf=_format_seconds(result.pdf_ocr_seconds), rate=_format_float(result.units_per_second, 3), count=result.recognized_lines, confidence=_format_float(result.mean_confidence), ) ) lines.extend(["", "## 结论", ""]) if by_ocr: fastest = by_ocr[0] lines.append( f"- 纯 OCR 耗时最短:`{fastest.scenario.name}`,耗时 `{fastest.pure_ocr_seconds:.3f}s`,吞吐 `{_format_float(fastest.units_per_second, 3)}` OCR 单位/秒。" ) if by_wall: fastest_wall = by_wall[0] lines.append( f"- 墙钟耗时最短:`{fastest_wall.scenario.name}`,耗时 `{fastest_wall.wall_seconds:.3f}s`。" ) if len(by_ocr) > 1 and by_ocr[-1].pure_ocr_seconds > 0: speedup = by_ocr[-1].pure_ocr_seconds / by_ocr[0].pure_ocr_seconds lines.append(f"- 最快与最慢完成场景的纯 OCR 速度差约为 `{speedup:.2f}x`。") lines.append("- 平均置信度仅作为结果稳定性的辅助观察值,不等价于有标注数据集上的识别准确率。") lines.extend(["", "## 参数说明", ""]) for name, profile in PROFILE_OPTIONS.items(): lines.append(f"- `{name}`:{profile['description']}。") lines.extend( [ "", "## 场景明细", "", ] ) for result in results: scenario = result.scenario lines.extend( [ f"### {scenario.name}", "", f"- 状态:`{result.status}`;退出码:`{result.exit_code}`", f"- 模型规格:`{scenario.model_size}`", f"- 预处理配置:`{scenario.profile}`", f"- PDF DPI:`{scenario.dpi}`", f"- CPU 线程:`{'auto' if scenario.threads is None else scenario.threads}`", f"- 检测边长:`{scenario.det_limit_side_len}`;限制方式:`{scenario.det_limit_type}`", f"- 检测阈值:`{scenario.det_thresh}`;文本框阈值:`{scenario.det_box_thresh}`;扩张比例:`{scenario.det_unclip_ratio}`", f"- 识别阈值:`{scenario.rec_score_thresh}`;识别 Batch Size:`{scenario.rec_batch_size}`", f"- 完成文件:`{result.processed_files}`(图片 `{result.image_files}`,PDF `{result.pdf_files}`,OCR 页 `{result.ocr_pages}`)", f"- 墙钟耗时:`{result.wall_seconds:.3f}s`", f"- 纯 OCR 耗时:`{result.pure_ocr_seconds:.3f}s`", f"- 输出目录:`{result.output_dir}`", f"- 运行日志:`{result.log_file}`", f"- 命令:`{' '.join(result.command)}`", ] ) if result.error: lines.append(f"- 错误:`{result.error}`") lines.append("") return "\n".join(lines).rstrip() + "\n" def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( description="对 data 目录执行 PP-OCRv6 参数矩阵测试并生成 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("--device-id", type=int, default=0, help="GPU 编号") parser.add_argument("--models", nargs="+", choices=MODEL_SIZES, default=list(MODEL_SIZES), help="模型规格列表") parser.add_argument("--profiles", nargs="+", choices=tuple(PROFILE_OPTIONS), default=["fast", "standard"], help="预处理配置列表") parser.add_argument("--dpis", nargs="+", type=int, default=[144], help="PDF 渲染 DPI 列表") parser.add_argument("--threads", nargs="+", default=["auto"], help="CPU 线程列表,可使用 auto") parser.add_argument("--det-limit-side-lens", nargs="+", type=int, default=[64], help="文本检测边长列表") parser.add_argument("--det-limit-types", nargs="+", choices=("min", "max"), default=["min"], help="文本检测边长限制方式列表") parser.add_argument("--det-thresholds", nargs="+", type=float, default=[0.3], help="文本检测像素阈值列表") parser.add_argument("--det-box-thresholds", nargs="+", type=float, default=[0.6], help="文本框阈值列表") parser.add_argument("--det-unclip-ratios", nargs="+", type=float, default=[1.5], help="文本框扩张比例列表") parser.add_argument("--rec-score-thresholds", nargs="+", type=float, default=[0.0], help="识别置信度阈值列表") parser.add_argument("--rec-batch-sizes", nargs="+", type=int, default=[6], help="文本识别 Batch Size 列表") parser.add_argument("--warmup", type=int, default=1, help="每个场景首张图片预热轮数") parser.add_argument("--rounds", type=int, default=1, help="每张图片推理轮数") parser.add_argument("--pdf-mode", choices=("ocr", "hybrid", "text"), default="ocr", help="PDF 测试模式;速度对比推荐 ocr") parser.add_argument("--output", type=Path, default=None, help="Markdown 报告路径") parser.add_argument("--work-dir", type=Path, default=None, help="各场景原始输出目录") parser.add_argument("--overwrite", action="store_true", help="允许删除已存在的 work-dir") parser.add_argument("--fail-fast", action="store_true", help="任一场景失败后停止") return parser def main(argv: list[str] | None = None) -> int: args = build_parser().parse_args(argv) data_dir = args.data.expanduser().resolve() if not data_dir.is_dir(): print(f"测试数据目录不存在: {data_dir}", file=sys.stderr) return 2 if args.warmup < 0 or args.rounds < 1: print("--warmup 必须 >= 0,--rounds 必须 >= 1", file=sys.stderr) return 2 if any(dpi < 72 or dpi > 600 for dpi in args.dpis): print("--dpis 必须在 72 到 600 之间", file=sys.stderr) return 2 if any(value < 1 for value in args.det_limit_side_lens): print("--det-limit-side-lens 必须大于等于 1", file=sys.stderr) return 2 if any(value < 1 for value in args.rec_batch_sizes): print("--rec-batch-sizes 必须大于等于 1", file=sys.stderr) return 2 try: thread_values = parse_threads(args.threads) except ValueError as exc: print(f"无效线程参数: {exc}", file=sys.stderr) return 2 timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") work_dir = (args.work_dir or DEFAULT_BENCHMARK_ROOT / timestamp).expanduser().resolve() report_path = (args.output or ROOT / "benchmarks" / f"参数测试报告-{timestamp}.md").expanduser().resolve() if work_dir.exists(): if not args.overwrite: print(f"测试输出目录已存在: {work_dir};请使用 --overwrite", file=sys.stderr) return 2 shutil.rmtree(work_dir) work_dir.mkdir(parents=True, exist_ok=True) report_path.parent.mkdir(parents=True, exist_ok=True) scenarios = build_scenarios( args.models, args.profiles, args.dpis, thread_values, args.det_limit_side_lens, args.det_limit_types, args.det_thresholds, args.det_box_thresholds, args.det_unclip_ratios, args.rec_score_thresholds, args.rec_batch_sizes, ) print(f"计划执行 {len(scenarios)} 个参数场景,报告将写入: {report_path}") started_at = datetime.now().astimezone() results: list[ScenarioResult] = [] for index, scenario in enumerate(scenarios, 1): scenario_dir = work_dir / scenario.name output_dir = scenario_dir / "outputs" log_file = scenario_dir / "run.log" scenario_dir.mkdir(parents=True, exist_ok=True) command = build_command( scenario, data_dir=data_dir, output_dir=output_dir, device=args.device, device_id=args.device_id, warmup=args.warmup, rounds=args.rounds, pdf_mode=args.pdf_mode, ) print(f"[{index}/{len(scenarios)}] {scenario.name}") wall_started = time.perf_counter() error = None try: completed = subprocess.run( command, cwd=ROOT, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=False, ) raw_output = completed.stdout or b"" log_file.write_bytes(raw_output) exit_code = completed.returncode if exit_code != 0: text = raw_output.decode("utf-8", errors="replace") error = next((line for line in reversed(text.splitlines()) if line.strip()), f"退出码 {exit_code}") except Exception as exc: exit_code = 1 error = f"{type(exc).__name__}: {exc}" log_file.write_text(error + "\n", encoding="utf-8") wall_seconds = time.perf_counter() - wall_started result = collect_scenario_metrics( scenario, output_dir=output_dir, log_file=log_file, command=command, exit_code=exit_code, wall_seconds=wall_seconds, error=error, ) results.append(result) print( f" 状态={result.status} 墙钟={result.wall_seconds:.3f}s " f"纯OCR={result.pure_ocr_seconds:.3f}s" ) if result.status != "completed" and args.fail_fast: break finished_at = datetime.now().astimezone() report = render_report( results, data_dir=data_dir, device=args.device, pdf_mode=args.pdf_mode, warmup=args.warmup, rounds=args.rounds, started_at=started_at, finished_at=finished_at, ) report_path.write_text(report, encoding="utf-8") summary_path = work_dir / "summary.json" summary_path.write_text( json.dumps( [ { **asdict(result), "scenario": asdict(result.scenario), } for result in results ], ensure_ascii=False, indent=2, ), encoding="utf-8", ) print(f"测试完成,Markdown 报告: {report_path}") print(f"原始汇总 JSON: {summary_path}") return 0 if all(result.status == "completed" for result in results) else 3 if __name__ == "__main__": raise SystemExit(main())