# Copyright (c) Opendatalab. All rights reserved. """验证真实 ONNX/Torch 模型及 PDF 解析,报告保留样本、输出和运行指标。""" from __future__ import annotations import argparse import copy import json import resource import sys import time from importlib.metadata import version from pathlib import Path from typing import Any import cv2 import numpy as np from PIL import Image def json_value(value: Any) -> Any: """把 NumPy 标量与数组转换成 JSON 原生类型,不改变数值或文字。""" if isinstance(value, np.generic): return value.item() if isinstance(value, np.ndarray): return value.tolist() raise TypeError(f"Unsupported JSON value: {type(value).__name__}") def write_json(path: Path, value: Any) -> None: """逐阶段保存结果,长时间验证被中断后仍可复查已完成样本。""" path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(value, ensure_ascii=False, indent=2, default=json_value) + "\n", encoding="utf-8") def overlay(image: np.ndarray, boxes: list[Any], path: Path) -> None: """输出检测轮廓与序号,供人工核对真实页面和印章裁剪。""" canvas = image.copy() for index, box in enumerate(boxes): points = np.asarray(box, dtype=np.int32).reshape(-1, 2) cv2.polylines(canvas, [points], True, (0, 160, 0), 2) cv2.putText(canvas, str(index), tuple(points[0]), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 0, 0), 1) path.parent.mkdir(parents=True, exist_ok=True) cv2.imwrite(str(path), canvas) def ocr_comparison(args: argparse.Namespace) -> dict[str, Any]: """对照 CPU 概率图、几何、弯曲裁剪与完整 OCR 文本,印章采用严格误差门槛。""" import torch from mineru.model.ocr.pp_ocr_v6_onnx import PPOCRv6ONNX from mineru.model.ocr.pytorch_paddle import PytorchPaddleOCR from mineru.model.registry import MINERU_4_MODELS_ONNX torch.set_num_threads(2) lang = "seal" if args.kind == "seal" else "ch" started = time.perf_counter() reference = PytorchPaddleOCR(lang=lang, device="cpu") repo = MINERU_4_MODELS_ONNX detector = repo.seal_det if lang == "seal" else repo.ocr_det candidate = PPOCRv6ONNX(str(detector.local_path()), str(repo.ocr_rec.local_path()), lang=lang) report: dict[str, Any] = {"initialization_seconds": time.perf_counter() - started, "samples": []} float64_reference = None for sample_index, path in enumerate(args.inputs): source = cv2.imread(str(path)) if source is None: raise ValueError(f"Cannot read image: {path}") # 印章覆盖原尺寸、放大和非方形边界,避免只验证随机输入或单一尺寸。 images = [source] if lang == "seal": images += [ cv2.resize(source, None, fx=1.5, fy=1.5), cv2.copyMakeBorder(source, 20, 0, 45, 10, cv2.BORDER_CONSTANT, value=(255, 255, 255)), ] for variant_index, image in enumerate(images): prefix = f"sample-{sample_index}-{variant_index}" full_input, _, _ = reference.text_detector._preprocess_det_image(image) light_input, _ = candidate.text_detector._preprocess(image) np.testing.assert_array_equal(full_input[np.newaxis, :], light_input) with torch.inference_mode(): full_maps = reference.text_detector.net(torch.from_numpy(light_input))["maps"].numpy() light_maps = candidate.text_detector.session.run(None, {candidate.text_detector.input_name: light_input})[0] error = float(np.abs(full_maps - light_maps).max()) probability_matches = bool(np.allclose(full_maps, light_maps, rtol=1e-4, atol=1e-5)) numerical_diagnostics = None if lang == "seal" and not probability_matches: # 保留原门槛的失败结果,用 FP64 参考定位 FP32 舍入,不通过放宽容差掩盖差异。 if float64_reference is None: float64_reference = copy.deepcopy(reference.text_detector.net).double() with torch.inference_mode(): precise_maps = float64_reference(torch.from_numpy(light_input).double())["maps"].numpy() numerical_diagnostics = { "strict_rtol": 1e-4, "strict_atol": 1e-5, "strict_violations": int((~np.isclose(full_maps, light_maps, rtol=1e-4, atol=1e-5)).sum()), "torch_fp32_max_error_vs_fp64": float(np.abs(full_maps - precise_maps).max()), "onnx_fp32_max_error_vs_fp64": float(np.abs(light_maps - precise_maps).max()), "threshold_mask_changes": int(((full_maps > 0.2) != (light_maps > 0.2)).sum()), } durations = {} outputs = {} saved_crops = {} for name, model in (("torch", reference), ("onnx", candidate)): started = time.perf_counter() result = model.ocr(image)[0] or [] durations[name] = time.perf_counter() - started outputs[name] = result overlay(image, [item[0] for item in result], args.output_dir / f"{prefix}-{name}.png") if lang == "seal": polygons, _ = model.text_detector(image) polygons = model._seal_sort_boxes(polygons) crops = model._seal_crop_by_polys(image, polygons) saved_crops[name] = crops for crop_index, crop in enumerate(crops): cv2.imwrite(str(args.output_dir / f"{prefix}-{name}-crop-{crop_index}.png"), crop) texts = {name: [item[1][0] for item in value] for name, value in outputs.items()} geometry_matches = [item[0] for item in outputs["torch"]] == [item[0] for item in outputs["onnx"]] entry = { "input": str(path), "variant": variant_index, "shape": image.shape, "max_probability_error": error, "probability_matches": probability_matches, "geometry_matches": geometry_matches, "text_matches": texts["torch"] == texts["onnx"], "texts": texts, "seconds": durations, "numerical_diagnostics": numerical_diagnostics, } if lang == "seal": entry["crops_match"] = len(saved_crops["torch"]) == len(saved_crops["onnx"]) and all( np.array_equal(a, b) for a, b in zip(saved_crops["torch"], saved_crops["onnx"]) ) report["samples"].append(entry) write_json(args.output_dir / "report.json", report) write_json(args.output_dir / f"{prefix}-outputs.json", outputs) if lang == "seal" and ( not entry["text_matches"] or not geometry_matches or not entry["crops_match"] or (numerical_diagnostics is not None and numerical_diagnostics["threshold_mask_changes"] != 0) ): raise AssertionError(f"Seal output differs for {prefix}; inspect saved outputs") return report def formula_comparison(args: argparse.Namespace) -> dict[str, Any]: """使用同一批公式裁图对照 Plus-M,两种执行路径分别保留完整结果与耗时。""" import torch from mineru.model.mfr.pp_formulanet.predict_formula import FormulaRecognizer from mineru.model.mfr.pp_formulanet_plus_m_onnx import PPFormulaNetPlusMONNX from mineru.model.registry import MINERU_4_MODELS_TORCH, MINERU_4_MODELS_ONNX torch.set_num_threads(2) images = [np.asarray(Image.open(path).convert("RGB")) for path in args.inputs] detections = [[{"label": "display_formula", "bbox": [0, 0, image.shape[1], image.shape[0]]}] for image in images] timings = {} results = {} for stack in ("torch", "onnx"): started = time.perf_counter() if stack == "torch": model = FormulaRecognizer(str(MINERU_4_MODELS_TORCH.pp_formulanet_plus_m_weights.local_path().parent), "cpu") else: model = PPFormulaNetPlusMONNX( str(MINERU_4_MODELS_ONNX.pp_formulanet_plus_m_weights.local_path()), str(MINERU_4_MODELS_ONNX.pp_formulanet_plus_m_config.local_path()), intra_op_num_threads=2, ) timings[f"{stack}_initialization"] = time.perf_counter() - started started = time.perf_counter() predictions = model.batch_predict(detections, images, batch_size=4) timings[f"{stack}_inference"] = time.perf_counter() - started results[stack] = [page[0]["latex"] for page in predictions] write_json(args.output_dir / f"{stack}.json", {"results": results[stack], "seconds": timings}) del model samples = [ {"input": str(path), "torch": full, "onnx": light, "match": full == light} for path, full, light in zip(args.inputs, results["torch"], results["onnx"]) ] return { "count": len(samples), "matched": sum(sample["match"] for sample in samples), "samples": samples, "seconds": timings, } def layout_comparison(args: argparse.Namespace) -> dict[str, Any]: """保存两种版面模型的标签、框、顺序和叠图,批次与单页必须一致。""" import torch from mineru.model.layout.pp_doclayout_v2_onnx import PPDocLayoutV2LayoutModelONNX from mineru.model.layout.pp_doclayoutv2 import PPDocLayoutV2LayoutModel from mineru.model.registry import MINERU_4_MODELS_TORCH, MINERU_4_MODELS_ONNX torch.set_num_threads(2) images = [Image.open(path).convert("RGB") for path in args.inputs] outputs = {} durations = {} for stack in ("torch", "onnx"): started = time.perf_counter() if stack == "torch": model = PPDocLayoutV2LayoutModel(str(MINERU_4_MODELS_TORCH.pp_doclayout_v2.local_path()), "cpu") else: model = PPDocLayoutV2LayoutModelONNX( str(MINERU_4_MODELS_ONNX.pp_doclayout_v2.local_path()), config_path=str(MINERU_4_MODELS_ONNX.pp_doclayout_v2_config.local_path()), intra_op_num_threads=2, ) durations[f"{stack}_initialization"] = time.perf_counter() - started started = time.perf_counter() outputs[stack] = model.batch_predict(images, batch_size=2) durations[f"{stack}_inference"] = time.perf_counter() - started if stack == "onnx": assert outputs[stack] == [model.predict(image) for image in images] for index, (image, predictions) in enumerate(zip(images, outputs[stack])): boxes = [[[x1, y1], [x2, y1], [x2, y2], [x1, y2]] for x1, y1, x2, y2 in [item["bbox"] for item in predictions]] overlay(cv2.cvtColor(np.asarray(image), cv2.COLOR_RGB2BGR), boxes, args.output_dir / f"page-{index}-{stack}.png") del model return {"outputs": outputs, "seconds": durations} def parse_documents(args: argparse.Namespace) -> dict[str, Any]: """通过产品 Parser 解析真实文档,保存结果包、重复运行耗时及导入边界。""" from mineru.kit.common import save_parse_result from mineru.parser import parse from mineru.parser.api_server import _preload_server_models report: dict[str, Any] = {"stack": args.small_backend, "tier": args.tier, "ocr_mode": args.ocr_mode, "documents": []} started = time.perf_counter() preload = _preload_server_models(args.tier, language="ch") report["preload_seconds"] = time.perf_counter() - started report["engine"] = preload.engine for path in args.inputs: durations = [] document_dir = args.output_dir / path.stem document_dir.mkdir(parents=True, exist_ok=True) for iteration in range(args.rounds): started = time.perf_counter() result = parse(path, tier=args.tier, ocr_mode=args.ocr_mode, page_range=args.pages) durations.append(time.perf_counter() - started) if iteration == 0: save_parse_result(result, document_dir / "result.zip", "zip") save_parse_result(result, document_dir / "markdown.md", "markdown") save_parse_result(result, document_dir / "middle_json.json", "middle_json") if result._model_output is not None: write_json(document_dir / "model_output.json", result._model_output.to_dict()) report["documents"].append( { "input": str(path), "pages": len(result.pages), "seconds": durations, "markdown_characters": len(result.markdown()), } ) write_json(args.output_dir / "report.json", report) report["heavy_modules_imported"] = [name for name in ("torch", "transformers") if name in sys.modules] if args.small_backend == "light" and report["heavy_modules_imported"]: raise AssertionError(f"Light imported heavy modules: {report['heavy_modules_imported']}") return report def main() -> None: """配置隔离的模型目录和本地来源,运行指定验证场景并写出报告。""" from mineru.config import config parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("kind", choices=["ocr", "seal", "mfr", "layout", "parse"]) parser.add_argument("inputs", type=Path, nargs="+") parser.add_argument("--models-dir", type=Path, default=Path("output/model-migration/models")) parser.add_argument("--output-dir", type=Path, required=True) parser.add_argument("--small-backend", choices=["onnx", "torch"], default="onnx") parser.add_argument("--tier", choices=["basic", "standard"], default="basic") parser.add_argument("--ocr-mode", choices=["auto", "txt", "ocr"], default="auto") parser.add_argument("--pages", default="") parser.add_argument("--rounds", type=int, default=1) args = parser.parse_args() args.output_dir.mkdir(parents=True, exist_ok=True) config.model.base_dir = str(args.models_dir.resolve()) config.model.source = "local" # 组件对照需要同时加载 Torch;完整 Light 解析则严格使用 Light 配置。 config.model.small_backend = args.small_backend if args.kind == "parse" else "torch" config.llm_aided.features.title_leveling = False config.llm_aided.features.cross_page_table_cell_merge = False operations = { "ocr": ocr_comparison, "seal": ocr_comparison, "mfr": formula_comparison, "layout": layout_comparison, "parse": parse_documents, } report = operations[args.kind](args) import mineru report["environment"] = { "python": sys.version.split()[0], "onnxruntime": version("onnxruntime"), "mineru": version("mineru"), "implementation": str(Path(mineru.__file__).resolve()), } report["peak_rss_bytes"] = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss * (1 if sys.platform == "darwin" else 1024) write_json(args.output_dir / "report.json", report) print(f"Saved {args.kind} validation to {args.output_dir}", flush=True) if __name__ == "__main__": main()