diff --git a/Directory.Build.props b/Directory.Build.props
index 5f8fc43..6665631 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -5,8 +5,9 @@
-->
2.0.0
- 1.0.0
+ 1.1.0
1.0.0
+ 1.0.0
1.0.0
1.0.0
1.0.0
@@ -44,6 +45,11 @@
true
Embedded PP-LCNet text-line orientation (CLS) model for PP-OCR.
+
+ $(SdcbSimdPaddleOCRModelsDocumentOrientationVersion)
+ true
+ Independent PP-LCNet_x1_0 document orientation model (0, 90, 180, 270 degrees). Simplified ONNX verified against Paddle.
+
$(SdcbSimdPaddleOCRModelsChineseV6TinyVersion)
true
diff --git a/Directory.Build.targets b/Directory.Build.targets
index d1336e5..a812d10 100644
--- a/Directory.Build.targets
+++ b/Directory.Build.targets
@@ -23,6 +23,9 @@
<_ProjectReferencesWithVersions Condition="'%(Filename)' == 'Sdcb.SimdPaddleOCR.Models.TextLineOrientation'">
$(SdcbSimdPaddleOCRModelsTextLineOrientationVersion)
+ <_ProjectReferencesWithVersions Condition="'%(Filename)' == 'Sdcb.SimdPaddleOCR.Models.DocumentOrientation'">
+ $(SdcbSimdPaddleOCRModelsDocumentOrientationVersion)
+
<_ProjectReferencesWithVersions Condition="'%(Filename)' == 'Sdcb.SimdPaddleOCR.Models.ChineseV6Tiny'">
$(SdcbSimdPaddleOCRModelsChineseV6TinyVersion)
diff --git a/README.md b/README.md
index 5d94d61..d936c2d 100644
--- a/README.md
+++ b/README.md
@@ -10,6 +10,8 @@ GPU 支持 Vulkan 与 Metal(macOS),仅 .NET 10;同样纯 C#、不内嵌
## 快速开始
+需要整张纸的 0°/90°/180°/270° 校正,可单独引用 `Sdcb.SimdPaddleOCR.Models.DocumentOrientation`,通过 `PaddleOcrAll.Load/LoadAsync` 显式传入 `DocumentOrientationModel.Provider`。该阶段在 DET 前执行;接入、模型转换校验与 CPU/GPU/WASM 实测见 [纸张方向识别](docs/document-orientation.md)。
+
安装核心包和 tiny 模型(tiny 会传递引用 CLS 与 `ModelProvider`):
```powershell
@@ -124,6 +126,7 @@ finally
| `Sdcb.SimdPaddleOCR.Models.ChineseV6Small` | [](https://www.nuget.org/packages/Sdcb.SimdPaddleOCR.Models.ChineseV6Small) | PP-OCRv6 small;`ChineseV6SmallModels.Default` |
| `Sdcb.SimdPaddleOCR.Models.ChineseV6Medium` | [](https://www.nuget.org/packages/Sdcb.SimdPaddleOCR.Models.ChineseV6Medium) | PP-OCRv6 medium;`ChineseV6MediumModels.Default` |
| `Sdcb.SimdPaddleOCR.Models.TextLineOrientation` | [](https://www.nuget.org/packages/Sdcb.SimdPaddleOCR.Models.TextLineOrientation) | PP-LCNet 文本行方向 CLS,被三个中文模型包传递引用 |
+| `Sdcb.SimdPaddleOCR.Models.DocumentOrientation` | 独立可选模型包 | PP-LCNet_x1_0 纸张四方向(0/90/180/270);在 DET 前执行 |
每个 `IPaddleOcrModelProvider` 提供 `Name`、`Kind`、`Format`、语言和版本元数据以及 `OpenRead()` / `OpenReadAsync()`。完整 OCR 组合由 `PaddleOcrModelBundle` 表达(DET、REC、字典和可选 CLS)。当前语言代码为 `zh`。单个模型也可被其他推理实现消费,例如 `ChineseV6TinyModel.Detection.OpenReadAsync()`。`Model`、`PaddleOcrDetector`、`PaddleOcrClassifier`、`PaddleOcrRecognizer` 和 `PaddleOcrAll` 均提供 Stream 加载入口;解析完成后不会继续保留完整的 ONNX 原始字节。
diff --git a/README_EN.md b/README_EN.md
index a45a36e..9960127 100644
--- a/README_EN.md
+++ b/README_EN.md
@@ -10,6 +10,8 @@ The core API accepts interleaved pixels (BGR24 by default; RGB24 / BGRA32 / RGBA
## Quick start
+For whole-page 0°/90°/180°/270° correction, reference the independent `Sdcb.SimdPaddleOCR.Models.DocumentOrientation` package and explicitly pass `DocumentOrientationModel.Provider` to `PaddleOcrAll.Load/LoadAsync` (with `options: null` or your options). The stage runs before detection and returns boxes in source-image coordinates. See [conversion, validation and CPU/GPU/WASM measurements](docs/document-orientation.md).
+
Install the core package and the tiny models (tiny transitively references CLS and `ModelProvider`):
```powershell
@@ -124,6 +126,7 @@ finally
| `Sdcb.SimdPaddleOCR.Models.ChineseV6Small` | [](https://www.nuget.org/packages/Sdcb.SimdPaddleOCR.Models.ChineseV6Small) | PP-OCRv6 small; `ChineseV6SmallModels.Default` |
| `Sdcb.SimdPaddleOCR.Models.ChineseV6Medium` | [](https://www.nuget.org/packages/Sdcb.SimdPaddleOCR.Models.ChineseV6Medium) | PP-OCRv6 medium; `ChineseV6MediumModels.Default` |
| `Sdcb.SimdPaddleOCR.Models.TextLineOrientation` | [](https://www.nuget.org/packages/Sdcb.SimdPaddleOCR.Models.TextLineOrientation) | PP-LCNet text-line orientation CLS, transitively referenced by the three Chinese model packages |
+| `Sdcb.SimdPaddleOCR.Models.DocumentOrientation` | Independent optional package | PP-LCNet_x1_0 whole-page orientation (0/90/180/270), before DET |
Each `IPaddleOcrModelProvider` exposes `Name`, `Kind`, `Format`, language and version metadata, plus `OpenRead()` / `OpenReadAsync()`. A full OCR set is a `PaddleOcrModelBundle` (DET, REC, dictionary, and optional CLS). The current language code is `zh`. Individual models can also be consumed by other inference implementations, for example `ChineseV6TinyModel.Detection.OpenReadAsync()`. `Model`, `PaddleOcrDetector`, `PaddleOcrClassifier`, `PaddleOcrRecognizer`, and `PaddleOcrAll` all accept Stream load entry points; after parsing they do not keep the full raw ONNX bytes.
diff --git a/Sdcb.SimdPaddleOCR.slnx b/Sdcb.SimdPaddleOCR.slnx
index 77acada..545478d 100644
--- a/Sdcb.SimdPaddleOCR.slnx
+++ b/Sdcb.SimdPaddleOCR.slnx
@@ -16,6 +16,7 @@
+
diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md
index ce72d43..66828b6 100644
--- a/THIRD-PARTY-NOTICES.md
+++ b/THIRD-PARTY-NOTICES.md
@@ -24,6 +24,13 @@ Sources:
-
-
-
+-
+-
+
+`doc_ori.onnx` and `textline_ori_x1.onnx` are derived by conversion and
+independent graph simplification from those two Paddle parameter models.
+Pinned revisions, source hashes, converter versions and numerical comparisons
+are recorded in `models/orientation-manifest.json`.
The model files are distributed for use with this project and remain subject
to the upstream model terms. PaddleOCR and PP-OCR are trademarks of their
diff --git a/build/00-common.linq b/build/00-common.linq
index 1674c12..7913e22 100644
--- a/build/00-common.linq
+++ b/build/00-common.linq
@@ -29,8 +29,9 @@ static void Run(string exe, string args, Encoding encoding) => Util.Cmd(exe, arg
static ProjectVersion[] Projects =
{
new("Sdcb.SimdPaddleOCR", "2.0.0"),
- new("Sdcb.SimdPaddleOCR.ModelProvider", "1.0.0"),
+ new("Sdcb.SimdPaddleOCR.ModelProvider", "1.1.0"),
new("Sdcb.SimdPaddleOCR.Models.TextLineOrientation", "1.0.0"),
+ new("Sdcb.SimdPaddleOCR.Models.DocumentOrientation", "1.0.0"),
new("Sdcb.SimdPaddleOCR.Models.ChineseV6Tiny", "1.0.0"),
new("Sdcb.SimdPaddleOCR.Models.ChineseV6Small", "1.0.0"),
new("Sdcb.SimdPaddleOCR.Models.ChineseV6Medium", "1.0.0"),
diff --git a/build/convert-orientation.py b/build/convert-orientation.py
new file mode 100644
index 0000000..57519e5
--- /dev/null
+++ b/build/convert-orientation.py
@@ -0,0 +1,183 @@
+"""下载固定版本的 Paddle 方向模型,导出 ONNX,独立简化并验证数值后写入交付模型。
+
+在仓库根目录使用隔离的 Python 3.12 环境运行:
+ pip install paddlepaddle==3.1.1 paddle2onnx==2.1.0 onnx==1.17.0 onnxsim==0.8.1
+ pip install opencv-python-headless==4.13.0.92 opt-einsum==3.3.0 polygraphy==0.49.26
+ python build/convert-orientation.py --endpoint https://hf-mirror.com
+默认下载地址为 Hugging Face;--endpoint 可指定镜像,--source-dir 指定原始模型目录,
+--sample 指定四方向测试图片。现有 OCR 模型不替换,数值校验失败时不写入该模型的交付 ONNX。
+"""
+import argparse
+from collections import Counter
+import hashlib
+import importlib.metadata
+import json
+import os
+from pathlib import Path
+import tempfile
+from unittest.mock import patch
+
+import cv2
+import numpy as np
+import onnx
+import onnxruntime as ort
+import requests
+
+ROOT = Path(__file__).resolve().parent.parent
+# 固定来源提交、模型输入高宽和交付文件名;纸张模型四分类,文本行模型仅两分类。
+SOURCES = {
+ "PP-LCNet_x1_0_doc_ori": ("d3b95a6dff5fe8a94f2748e12b61cb26818a0df8", (224, 224), "doc_ori.onnx"),
+ "PP-LCNet_x1_0_textline_ori": ("cd237a44b0e359d4fe38310a416203cf7403faa5", (80, 160), "textline_ori_x1.onnx"),
+}
+
+
+def digest(path):
+ """计算源文件或交付文件的 SHA-256,供转换记录追溯来源。
+
+ 参数:path 为待读取的 pathlib.Path。
+ 返回:小写十六进制哈希字符串。
+ """
+ return hashlib.sha256(path.read_bytes()).hexdigest()
+
+
+def preprocess(bgr, document):
+ """按官方分类预处理生成连续 float32 NCHW 输入。
+
+ 参数:bgr 为 H×W×3 的八位 BGR 图像;document 为 True 时执行纸张模型的
+ 短边 256 缩放及中心 224 裁剪,为 False 时按文本行模型缩放至宽 160、高 80。
+ 返回:RGB 归一化后的 [1, 3, H, W] NumPy 数组。
+ """
+ rgb = bgr[:, :, ::-1]
+ if document:
+ h, w = rgb.shape[:2]
+ scale = 256 / min(h, w)
+ rgb = cv2.resize(rgb, (round(w * scale), round(h * scale)), interpolation=cv2.INTER_LINEAR)
+ h, w = rgb.shape[:2]
+ rgb = rgb[(h - 224) // 2:(h - 224) // 2 + 224, (w - 224) // 2:(w - 224) // 2 + 224]
+ else:
+ rgb = cv2.resize(rgb, (160, 80), interpolation=cv2.INTER_LINEAR)
+ data = rgb.astype(np.float32)
+ # 按 PaddleX 的顺序执行 float32 乘法再加法,避免把舍入差异误认为转换误差。
+ for c, (mean, std) in enumerate(zip([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])):
+ data[:, :, c] *= np.float32(1 / 255 / std)
+ data[:, :, c] += np.float32(-mean / std)
+ return np.ascontiguousarray(data.transpose(2, 0, 1)[None])
+
+
+def session(path):
+ """创建不启用运行时图优化的 ONNX Runtime CPU 会话。
+
+ 参数:path 为原始或简化后的 ONNX 文件路径。
+ 返回:使用 4 个算子线程的 CPU 会话,直接验证文件中的图,不让运行时代为修复。
+ """
+ opts = ort.SessionOptions()
+ opts.intra_op_num_threads = 4
+ # 比较文件中的原图和简化图,禁用 ORT 图优化,避免运行时改变待验证节点。
+ opts.graph_optimization_level = ort.GraphOptimizationLevel.ORT_DISABLE_ALL
+ return ort.InferenceSession(str(path), opts, providers=["CPUExecutionProvider"])
+
+
+def main():
+ """解析命令行,下载并验证两个方向模型,最后保存模型和转换记录。
+
+ 命令行参数:--endpoint 为下载服务根地址;--source-dir 为原始 Paddle 文件目录;
+ --sample 为用于 0/90/180/270 度比对的样例图片。无函数参数和返回值。
+ 任意文件、形状或数值检查失败时抛出异常,不写入尚未验证通过的模型。
+ """
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--endpoint", default="https://huggingface.co")
+ parser.add_argument("--source-dir", type=Path, default=ROOT / ".tmp-artifacts/orientation-source")
+ parser.add_argument("--sample", type=Path, default=ROOT / "examples/sample.jpg")
+ args = parser.parse_args()
+ scratch = ROOT / ".tmp-artifacts/orientation-validation"
+ scratch.mkdir(parents=True, exist_ok=True)
+ # Paddle 导入数据集模块时尝试写入用户目录;临时触发其只读目录回退,改用工作区临时目录。
+ tempfile.tempdir = str(scratch)
+ original_access = os.access
+ profile = os.path.expanduser("~")
+ with patch("os.access", lambda p, m: False if str(p) == profile else original_access(p, m)):
+ import paddle
+ import paddle2onnx
+ from onnxsim import simplify
+
+ image = cv2.imread(str(args.sample))
+ if image is None:
+ raise ValueError(f"Cannot read {args.sample}")
+ report = {"tools": {p: importlib.metadata.version(p) for p in
+ ["paddlepaddle", "paddle2onnx", "onnx", "onnxsim", "onnxruntime", "opencv-python-headless"]},
+ "sample": {"path": str(args.sample.relative_to(ROOT)), "sha256": digest(args.sample)}, "models": {}}
+ fixtures = []
+ rng = np.random.default_rng(137)
+ for name, (revision, (h, w), destination) in SOURCES.items():
+ source = args.source_dir / name
+ source.mkdir(parents=True, exist_ok=True)
+ source_info = {}
+ for filename in ["inference.json", "inference.pdiparams", "inference.yml", "config.json", "README.md"]:
+ path = source / filename
+ if not path.exists():
+ response = requests.get(f"{args.endpoint.rstrip('/')}/PaddlePaddle/{name}/resolve/{revision}/{filename}", timeout=90)
+ response.raise_for_status()
+ path.write_bytes(response.content)
+ source_info[filename] = {"sha256": digest(path), "bytes": path.stat().st_size}
+ raw = scratch / (name + ".raw.onnx")
+ simplified = scratch / (name + ".sim.onnx")
+ # 导出器不启用内置优化;图结构和参数必须成对使用,随后再交给独立 onnxsim 简化。
+ paddle2onnx.export(str(source / "inference.json"), str(source / "inference.pdiparams"),
+ save_file=str(raw), opset_version=13, optimize_tool=None)
+ raw_model = onnx.load(raw)
+ onnx.checker.check_model(raw_model, full_check=True)
+ input_name = raw_model.graph.input[0].name
+ # 固定真实输入形状,执行独立简化、严格形状推导和完整 ONNX 校验。
+ sim, checked = simplify(raw_model, overwrite_input_shapes={input_name: [1, 3, h, w]})
+ if not checked:
+ raise AssertionError("onnxsim rejected its simplified graph")
+ sim = onnx.shape_inference.infer_shapes(sim, strict_mode=True)
+ onnx.checker.check_model(sim, full_check=True)
+ onnx.save(sim, simplified)
+ print(name, "nodes", len(raw_model.graph.node), "->", len(sim.graph.node), Counter(n.op_type for n in sim.graph.node), flush=True)
+ config = paddle.inference.Config(str(source / "inference.json"), str(source / "inference.pdiparams"))
+ config.disable_gpu()
+ config.disable_mkldnn()
+ config.set_cpu_math_library_num_threads(4)
+ predictor = paddle.inference.create_predictor(config)
+ raw_session, sim_session = session(raw), session(simplified)
+ # 随机输入、常量输入和样例四方向共同覆盖数值和标签;固定随机种子确保可重复。
+ cases = [(f"random-{i}", rng.uniform(-2.2, 2.7, (1, 3, h, w)).astype(np.float32)) for i in range(8)]
+ cases += [(f"constant-{v}", np.full((1, 3, h, w), v, np.float32)) for v in [-2.0, 0.0, 2.0]]
+ cases += [(f"sample-{angle}", preprocess(np.ascontiguousarray(np.rot90(image, -angle // 90)), h == 224)) for angle in [0, 90, 180, 270]]
+ comparisons = []
+ for case_name, data in cases:
+ handle = predictor.get_input_handle(predictor.get_input_names()[0])
+ handle.reshape(data.shape)
+ handle.copy_from_cpu(data)
+ predictor.run()
+ expected = predictor.get_output_handle(predictor.get_output_names()[0]).copy_to_cpu()
+ exported = raw_session.run(None, {input_name: data})[0]
+ optimized = sim_session.run(None, {input_name: data})[0]
+ # 原始 Paddle 是独立参考;原 ONNX、简化 ONNX 都必须满足误差上限且最高分类一致。
+ for actual in [exported, optimized]:
+ np.testing.assert_allclose(actual, expected, rtol=1e-4, atol=2e-5)
+ assert np.argmax(actual) == np.argmax(expected), (case_name, actual, expected)
+ np.testing.assert_allclose(optimized, exported, rtol=1e-5, atol=2e-6)
+ comparisons.append({"case": case_name, "paddle_raw_max_abs": float(np.max(np.abs(expected - exported))),
+ "paddle_sim_max_abs": float(np.max(np.abs(expected - optimized))),
+ "raw_sim_max_abs": float(np.max(np.abs(exported - optimized))),
+ "label": int(np.argmax(expected)), "scores": expected.tolist()})
+ if h == 224:
+ tensor_path = scratch / (case_name + ".f32")
+ data.tofile(tensor_path)
+ fixtures.append({"case": case_name, "input": tensor_path.name, "scores": expected.reshape(-1).tolist()})
+ # 只有该模型的全部独立比较通过,才将简化后的文件写入交付目录。
+ output = ROOT / "models" / destination
+ output.write_bytes(simplified.read_bytes())
+ report["models"][name] = {"repository": f"https://huggingface.co/PaddlePaddle/{name}", "revision": revision,
+ "source_files": source_info, "output": destination, "sha256": digest(output),
+ "raw_nodes": len(raw_model.graph.node), "simplified_nodes": len(sim.graph.node),
+ "operators": dict(Counter(n.op_type for n in sim.graph.node)), "comparisons": comparisons}
+ (scratch / "fixtures.json").write_text(json.dumps(fixtures, indent=2), encoding="utf-8")
+ (ROOT / "models/orientation-manifest.json").write_text(json.dumps(report, indent=2), encoding="utf-8")
+ print("Paddle -> raw ONNX -> onnxsim numerical checks passed; model assets written.", flush=True)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/build/create-orientation-test-fixtures.py b/build/create-orientation-test-fixtures.py
new file mode 100644
index 0000000..a33d3c2
--- /dev/null
+++ b/build/create-orientation-test-fixtures.py
@@ -0,0 +1,56 @@
+"""生成固定、体积较小的 Gemm ONNX 用例和 OpenCV 预处理黄金值。
+
+从仓库根目录运行,无命令行参数。输出到单元测试 Assets/Orientation 目录,
+用于独立验证矩阵转置、缩放、偏置广播及纸张模型的 RGB 中心裁剪归一化。
+"""
+import json
+from pathlib import Path
+import cv2
+import numpy as np
+import onnx
+from onnx import helper, numpy_helper
+
+root = Path(__file__).resolve().parent.parent
+out = root / "test/Sdcb.SimdPaddleOCR.UnitTests/Assets/Orientation"
+out.mkdir(parents=True, exist_ok=True)
+cases = []
+# 分别覆盖普通偏置、双输入转置及缩放、列偏置广播,以及完全不提供偏置。
+for name, trans_a, trans_b, alpha, beta, bias_shape in [
+ ("gemm", 0, 0, 1.0, 1.0, [2]),
+ ("gemm-transpose-scale", 1, 1, 0.75, 1.5, [2]),
+ ("gemm-column-bias", 0, 1, -0.5, 0.25, [2, 1]),
+ ("gemm-no-bias", 0, 0, 2.0, 1.0, None),
+]:
+ a = np.arange(6, dtype=np.float32).reshape(2, 3) - 2
+ b = (np.arange(6, dtype=np.float32).reshape(3, 2) + 1) / 4
+ raw_a = a.T.copy() if trans_a else a
+ raw_b = b.T.copy() if trans_b else b
+ bias = np.arange(2, dtype=np.float32).reshape(bias_shape) + 0.25 if bias_shape else None
+ nodes = []
+ inputs = ["x", "w"]
+ if bias is not None:
+ # 故意让 TensorProto 的描述名称与 Constant 输出边不同,复现简化器可能生成的命名。
+ nodes.append(helper.make_node("Constant", [], ["bias-edge"], value=numpy_helper.from_array(bias, name="descriptive-bias")))
+ inputs.append("bias-edge")
+ nodes.append(helper.make_node("Gemm", inputs, ["y"], transA=trans_a, transB=trans_b, alpha=alpha, beta=beta))
+ graph = helper.make_graph(nodes, name, [helper.make_tensor_value_info("x", 1, raw_a.shape)],
+ [helper.make_tensor_value_info("y", 1, [2, 2])], [numpy_helper.from_array(raw_b, name="w")])
+ model = onnx.shape_inference.infer_shapes(helper.make_model(graph, opset_imports=[helper.make_opsetid("", 13)], ir_version=8))
+ onnx.checker.check_model(model)
+ onnx.save(model, out / (name + ".onnx"))
+ expected = alpha * (a @ b) + (beta * bias if bias is not None else 0)
+ cases.append({"name": name, "shape": list(raw_a.shape), "input": raw_a.ravel().tolist(), "expected": expected.ravel().tolist()})
+(out / "gemm.json").write_text(json.dumps(cases, indent=2), encoding="utf-8")
+# 非正方形的确定性输入能覆盖缩放后的中心裁剪偏移,黄金值由 OpenCV 独立生成。
+width, height = 17, 19
+bgr = ((np.arange(width * height * 3, dtype=np.int32) * 37 + 11) % 256).astype(np.uint8).reshape(height, width, 3)
+rgb = cv2.resize(bgr[:, :, ::-1], (round(width * 256 / min(width, height)), round(height * 256 / min(width, height))))
+y, x = (rgb.shape[0] - 224) // 2, (rgb.shape[1] - 224) // 2
+rgb = rgb[y:y + 224, x:x + 224].astype(np.float32)
+for c, (mean, std) in enumerate(zip([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])):
+ rgb[:, :, c] *= np.float32(1 / 255 / std)
+ rgb[:, :, c] += np.float32(-mean / std)
+# 采样裁剪边界和内部位置,测试时按 NCHW/NHWC 分别索引同一套 RGB 黄金值。
+samples = [{"channel": c, "x": x, "y": y, "value": float(rgb[y, x, c])}
+ for c in range(3) for x in [0, 1, 16, 57, 112, 173, 222, 223] for y in [0, 7, 111, 222, 223]]
+(out / "preprocess.json").write_text(json.dumps(samples, indent=2), encoding="utf-8")
diff --git a/docs/document-orientation-validation.json b/docs/document-orientation-validation.json
new file mode 100644
index 0000000..f182a6a
--- /dev/null
+++ b/docs/document-orientation-validation.json
@@ -0,0 +1,273 @@
+{
+ "date": "2026-10-13",
+ "hardware": {
+ "cpu": "Intel Core i7-12700",
+ "gpu": "NVIDIA GeForce RTX 3060",
+ "gpu_driver": "610.88"
+ },
+ "test_sample": "examples/sample.jpg",
+ "unit_tests": {
+ "net10": 131,
+ "netstandard20": 128,
+ "failed": 0
+ },
+ "gpu_profile_min_dispatch_total_ms": {
+ "before": 4.52,
+ "after": 2.4
+ },
+ "runs": {
+ "cpu": {
+ "backend": "Cpu",
+ "iterations": 20,
+ "measurements": [
+ {
+ "threads": 1,
+ "graph_median_ms": 5.5706,
+ "preprocess_median_ms": 0.5009,
+ "max_abs": 1.3947486877441406e-05,
+ "gpu_alive": false
+ },
+ {
+ "threads": 2,
+ "graph_median_ms": 3.8476,
+ "preprocess_median_ms": 0.5194,
+ "max_abs": 1.3947486877441406e-05,
+ "gpu_alive": false
+ },
+ {
+ "threads": 4,
+ "graph_median_ms": 3.1503,
+ "preprocess_median_ms": 0.5312,
+ "max_abs": 1.3947486877441406e-05,
+ "gpu_alive": false
+ },
+ {
+ "threads": 8,
+ "graph_median_ms": 3.1892,
+ "preprocess_median_ms": 0.5317,
+ "max_abs": 1.3947486877441406e-05,
+ "gpu_alive": false
+ }
+ ],
+ "pipeline": [
+ {
+ "angle": 0,
+ "lines": 16,
+ "hash": 5105274828408708791,
+ "elapsed_ms": 181.1452
+ },
+ {
+ "angle": 90,
+ "lines": 16,
+ "hash": 5105274828408708791,
+ "elapsed_ms": 68.1861
+ },
+ {
+ "angle": 180,
+ "lines": 16,
+ "hash": 5105274828408708791,
+ "elapsed_ms": 61.7194
+ },
+ {
+ "angle": 270,
+ "lines": 16,
+ "hash": 5105274828408708791,
+ "elapsed_ms": 61.7135
+ }
+ ],
+ "passed": true
+ },
+ "vulkan": {
+ "backend": "Vulkan",
+ "iterations": 20,
+ "measurements": [
+ {
+ "threads": 1,
+ "graph_median_ms": 2.054,
+ "preprocess_median_ms": 0.5006,
+ "max_abs": 0.00027829408645629883,
+ "gpu_alive": true
+ }
+ ],
+ "pipeline": [
+ {
+ "angle": 0,
+ "lines": 16,
+ "hash": 5105274828408708791,
+ "elapsed_ms": 576.3457
+ },
+ {
+ "angle": 90,
+ "lines": 16,
+ "hash": 5105274828408708791,
+ "elapsed_ms": 63.9473
+ },
+ {
+ "angle": 180,
+ "lines": 16,
+ "hash": 5105274828408708791,
+ "elapsed_ms": 64.8757
+ },
+ {
+ "angle": 270,
+ "lines": 16,
+ "hash": 5105274828408708791,
+ "elapsed_ms": 57.3169
+ }
+ ],
+ "passed": true
+ },
+ "vulkan_baseline": {
+ "backend": "Vulkan",
+ "iterations": 20,
+ "measurements": [
+ {
+ "threads": 1,
+ "graph_median_ms": 4.0628,
+ "preprocess_median_ms": 0.5529,
+ "max_abs": 0.00027829408645629883,
+ "gpu_alive": true
+ }
+ ],
+ "pipeline": [],
+ "passed": true
+ },
+ "vulkan_short_tuned": {
+ "backend": "Vulkan",
+ "iterations": 20,
+ "measurements": [
+ {
+ "threads": 1,
+ "graph_median_ms": 4.01,
+ "preprocess_median_ms": 0.8068,
+ "max_abs": 0.00027829408645629883,
+ "gpu_alive": true
+ }
+ ],
+ "pipeline": [],
+ "passed": true
+ },
+ "wasm_interpreter": {
+ "backend": "Cpu",
+ "iterations": 20,
+ "measurements": [
+ {
+ "threads": 1,
+ "graph_median_ms": 54.570068,
+ "preprocess_median_ms": 2.919922,
+ "max_abs": 1.3172626495361328e-05,
+ "gpu_alive": true
+ },
+ {
+ "threads": 2,
+ "graph_median_ms": 31.71997,
+ "preprocess_median_ms": 2.859864,
+ "max_abs": 1.3172626495361328e-05,
+ "gpu_alive": true
+ },
+ {
+ "threads": 4,
+ "graph_median_ms": 22.090088,
+ "preprocess_median_ms": 2.940186,
+ "max_abs": 1.3172626495361328e-05,
+ "gpu_alive": true
+ },
+ {
+ "threads": 8,
+ "graph_median_ms": 22.119874,
+ "preprocess_median_ms": 2.954834,
+ "max_abs": 1.3172626495361328e-05,
+ "gpu_alive": true
+ }
+ ],
+ "pipeline": [
+ {
+ "angle": 0,
+ "lines": 16,
+ "hash": 5105274828408708791,
+ "elapsed_ms": 1686.8798
+ },
+ {
+ "angle": 90,
+ "lines": 16,
+ "hash": 5105274828408708791,
+ "elapsed_ms": 1555.7199
+ },
+ {
+ "angle": 180,
+ "lines": 16,
+ "hash": 5105274828408708791,
+ "elapsed_ms": 1518.3598
+ },
+ {
+ "angle": 270,
+ "lines": 16,
+ "hash": 5105274828408708791,
+ "elapsed_ms": 1481.2248
+ }
+ ],
+ "passed": true
+ },
+ "wasm_aot": {
+ "backend": "Cpu",
+ "iterations": 20,
+ "measurements": [
+ {
+ "threads": 1,
+ "graph_median_ms": 8.424805,
+ "preprocess_median_ms": 0.770019,
+ "max_abs": 1.3172626495361328e-05,
+ "gpu_alive": false
+ },
+ {
+ "threads": 2,
+ "graph_median_ms": 6.044922,
+ "preprocess_median_ms": 0.75,
+ "max_abs": 1.3172626495361328e-05,
+ "gpu_alive": false
+ },
+ {
+ "threads": 4,
+ "graph_median_ms": 3.949951,
+ "preprocess_median_ms": 0.745117,
+ "max_abs": 1.3172626495361328e-05,
+ "gpu_alive": false
+ },
+ {
+ "threads": 8,
+ "graph_median_ms": 3.669921,
+ "preprocess_median_ms": 0.745118,
+ "max_abs": 1.3172626495361328e-05,
+ "gpu_alive": false
+ }
+ ],
+ "pipeline": [
+ {
+ "angle": 0,
+ "lines": 16,
+ "hash": 5105274828408708791,
+ "elapsed_ms": 447.7548
+ },
+ {
+ "angle": 90,
+ "lines": 16,
+ "hash": 5105274828408708791,
+ "elapsed_ms": 106.76
+ },
+ {
+ "angle": 180,
+ "lines": 16,
+ "hash": 5105274828408708791,
+ "elapsed_ms": 91.1599
+ },
+ {
+ "angle": 270,
+ "lines": 16,
+ "hash": 5105274828408708791,
+ "elapsed_ms": 92.2548
+ }
+ ],
+ "passed": true
+ }
+ }
+}
\ No newline at end of file
diff --git a/docs/document-orientation.md b/docs/document-orientation.md
new file mode 100644
index 0000000..9813e98
--- /dev/null
+++ b/docs/document-orientation.md
@@ -0,0 +1,112 @@
+# 四方向纸张识别
+
+本机 CPU / GPU 持续负载的耗时、CPU/GPU 使用率及内存/显存测试见 [资源性能报告](resource-performance.md)。
+
+`PaddleOcrAll` 支持可选的第一个阶段:纸张方向识别 → 整图旋转校正 → DET → 文本行 CLS → REC。
+
+纸张模型为 [PaddlePaddle/PP-LCNet_x1_0_doc_ori](https://huggingface.co/PaddlePaddle/PP-LCNet_x1_0_doc_ori),标签按顺序为 0、90、180、270。用户指定的 [PP-LCNet_x1_0_textline_ori](https://huggingface.co/PaddlePaddle/PP-LCNet_x1_0_textline_ori) 只有 0、180 两类,不能替代四方向纸张模型;它也已下载并独立转换为 `models/textline_ori_x1.onnx`,没有替换原有文本行模型。
+
+## 独立 NuGet 与调用
+
+新包 `Sdcb.SimdPaddleOCR.Models.DocumentOrientation` 只包含纸张模型和转换记录,并只依赖 `ModelProvider`。核心库与现有 ChineseV6Tiny/Small/Medium 模型包没有引用它,现有调用不会隐式启用该阶段。
+
+```csharp
+using Sdcb.SimdPaddleOCR;
+using Sdcb.SimdPaddleOCR.Models.ChineseV6Tiny;
+using Sdcb.SimdPaddleOCR.Models.DocumentOrientation;
+
+using PaddleOcrAll ocr = await PaddleOcrAll.LoadAsync(
+ ChineseV6TinyModels.Default,
+ documentOrientation: DocumentOrientationModel.Provider,
+ options: new PaddleOcrOptions
+ {
+ DocumentOrientation = new()
+ {
+ Backend = OcrBackend.Auto, // 可指定 Cpu 或 Vulkan
+ IntraOpThreads = 0 // 默认最多 4;允许 1..16
+ }
+ });
+
+PaddleOcrResult result = ocr.Run(pixels, width, height, returnCtcAlignment: true);
+Console.WriteLine(result.DocumentOrientation?.OrientationDegrees);
+Console.WriteLine(result.AppliedDocumentRotationDegrees);
+Console.WriteLine(result.Text);
+```
+
+传入 `options: null` 使用默认配置。`UseDocumentOrientationClassification = false` 关闭阶段;`DocumentOrientationThreshold` 默认 0,遵循模型 top-1,也可设置 0..1 的置信度门槛。`DocumentOrientation` 为 null 表示未运行纸张阶段。已有 `UseDirectionClassification` 仍控制裁剪后的文本行 CLS。
+
+`OrientationDegrees` 是输入页面的顺时针方向;`AppliedDocumentRotationDegrees` 是实际执行的逆时针校正角度。旋转只使用整数像素拷贝,保留原像素与四种像素格式,支持行尾 padding;输入内存不会被修改。非零校正使用每次请求独立的池化缓冲。
+
+返回的 `PaddleOcrLine.Box` 和 `EstimateCharacterBoxes()` 都使用原始输入图坐标,顶点保留校正后阅读方向的顺序,不能假设第一个点永远是原图中的左上角。行排序遵循校正后页面的阅读顺序。`DetectorResizedWidth/Height` 是校正后 DET 实际输入尺寸。行级 `AppliedRotationDegrees` 仍只描述文本行的 180° 校正。
+
+单独使用或者从本机模型加载:
+
+```csharp
+using Stream stream = File.OpenRead("models/doc_ori.onnx");
+using var direction = new PaddleOcrDocumentOrientationClassifier(stream,
+ new() { Backend = OcrBackend.Cpu, IntraOpThreads = 4 });
+var predicted = direction.Classify(pixels, width, height);
+```
+
+完整流程也可在 `PaddleOcrAll` 的 `Model` 构造函数最后传入 `documentOrientationModel`。调用方拥有传入的 `Model`;通过独立包的 Load/LoadAsync 加载时由 OCR 引擎统一释放。阶段共享不可变编译模型,池化每次调用的推理请求,支持并发 Run。
+
+## 转换与数值验证
+
+Paddle 模型由 `inference.json` 图结构和 `inference.pdiparams` 参数共同构成,不能只转换 pdiparams。原始文件在 `.tmp-artifacts/orientation-source/`,下载固定的仓库 revision,文件 SHA-256 记录于 `models/orientation-manifest.json`。
+
+`build/convert-orientation.py` 使用隔离 Python 环境:
+
+```powershell
+python -m venv --system-site-packages .tmp-artifacts/orientation-venv
+.tmp-artifacts/orientation-venv/Scripts/python -m pip install paddlepaddle==3.1.1 paddle2onnx==2.1.0 onnx==1.17.0 onnxsim==0.8.1 opencv-python-headless==4.13.0.92 opt-einsum==3.3.0 polygraphy==0.49.26 onnxruntime==1.24.3 requests
+.tmp-artifacts/orientation-venv/Scripts/python build/convert-orientation.py
+# 如本机不能直连 Hugging Face,可传 --endpoint https://hf-mirror.com
+```
+
+Windows 上这套 Paddle2ONNX 的预编译 DLL 与 Paddle 3.2.2/3.3.1 不兼容;已实测 3.1.1 可以导出。固定版本避免依赖更新改变转换结果。
+
+流程为:Paddle2ONNX 不启用内置优化 → ONNX checker → 独立 onnxsim 简化并固定 `[1,3,224,224]` → 严格形状推断/checker → Paddle 原始推理、原始 ONNX、简化 ONNX 三方比较。ONNX Runtime 的图优化关闭,避免把修复交给运行时。全部数值检查通过后才写入交付模型。
+
+每个模型测试 8 组随机输入、3 组常数输入和样例的 4 个方向。两模型均由 310 节点简化为 113 节点;纸张模型对 Paddle 的最大绝对误差为 `3.5763e-7`,指定文本行模型为 `4.1724e-7`,全部 top-1 标签一致。完整逐例分数、误差、转换器版本、revision 和哈希在 manifest 中。
+
+## 算子与本机调优
+
+简化后的 Gemm 导入时降低为已优化的 MatMul/Add。支持 transA/transB、alpha/beta、可选 bias 及广播;常量 B 转置与缩放在加载时完成,从而继续使用 CPU/WASM 打包权重和 GPU 算子。同步修复 Constant 内部 TensorProto 名称与输出边名称不同时丢失常量的问题。
+
+预处理遵循 PaddleX 的 RGB、短边缩放至 256、中心裁剪 224、ImageNet 归一化。缩放/裁剪/归一化合并为一次写入,直接产生后端所需 NCHW/NHWC;仅计算中心裁剪区域,宽图不会分配一张完整的缩放图。OpenCV 生成的黄金值覆盖 RGB/BGR/RGBA/BGRA、非紧密 stride 和两种张量布局。
+
+Vulkan 在 sg32 协作矩阵设备上,对不超过 256 行、256/512 输入通道的小 1×1 特征图选择 Split-K 点积,提高 LCNet 小特征图和分类颈部的占用率。`SIMD_OCR_NARROW_DOT=0` 可做旧路径对比。既有 Conv-HardSwish、5×5 depthwise、SE、池化融合由 CPU/WASM/Vulkan 的对应路径执行。默认 CPU 阶段选最多 4 线程,允许独立覆盖。
+
+本机为 Intel i7-12700、RTX 3060,测试日期 2026-10-13。使用样例图的四方向,预热 3 次,20 次计时取中位数;以下是方向图推理耗时,预处理单独测量,模型加载、整页旋转和其余 OCR 阶段不包含在表内。
+
+| 后端 | 设置 | 图推理约耗时 |
+| --- | --- | --- |
+| CPU | 1 线程 | 5.6 ms |
+| CPU | 4 线程(默认) | 3.2 ms |
+| Vulkan | 调优前 | 5.3 ms |
+| Vulkan | 小特征图点积调优后 | 4.1 ms |
+| WASM 解释执行 | 1 线程 | 54.6 ms |
+| WASM 解释执行 | 4 线程 | 22.1 ms |
+| WASM AOT | 1 线程 | 8.4 ms |
+| WASM AOT | 4 线程(默认) | 4.0 ms |
+| WASM AOT | 8 线程 | 3.7 ms |
+
+CPU 预处理约 0.53 ms,WASM 解释执行预处理约 2.94 ms。GPU 的四方向最大分数误差约 `2.8e-4`,与 fp16 中间计算有关,top-1 全部一致。CPU/Vulkan/WASM 都验证了整条 OCR 的四方向文本、行框和字符框;每个方向均为 16 行、文本 hash `46d99468540b5eb7`。这组结果是本机样例的性能与正确性检查,不代表数据集方向识别准确率。
+
+WASM AOT 的预处理约 0.75 ms。Windows GPU 时钟与调度会影响短测,调优路径在不同轮次的墙钟中位数约 2.1~4.1 ms;同一轮短测旧/新路径也曾为 4.06/4.01 ms。GPU profiler 的逐 dispatch 最小值之和由 4.52 ms 降至 2.40 ms,14×14 的 256→256 点积 dispatch 从约 0.22 ms 降至 0.07 ms。以上是算子路径选择的证据,不能将其等同于任意负载下整页 OCR 的固定加速倍数。
+
+验证包括 .NET 10 的 131 项单元测试及 netstandard2.0 的 128 项单元测试,全部通过。完整浏览器 AOT 的最后一轮还逐点验证了四方向还原后的字符框坐标。数据快照见 [document-orientation-validation.json](document-orientation-validation.json)。
+
+重现与打包:
+
+```powershell
+dotnet test test/Sdcb.SimdPaddleOCR.UnitTests -c Release
+dotnet test test/Sdcb.SimdPaddleOCR.UnitTests -c Release -p:UseNs20Library=true
+dotnet run --project test/Sdcb.SimdPaddleOCR.GpuBench -c Release -- --orientation --cpu
+dotnet run --project test/Sdcb.SimdPaddleOCR.GpuBench -c Release -- --orientation
+test/Sdcb.SimdPaddleOCR.WasmBench/run.ps1 -Orientation -NoAot -Out bench-out/orientation-wasm-interpreter.json
+test/Sdcb.SimdPaddleOCR.WasmBench/run.ps1 -Orientation -Out bench-out/orientation-wasm-aot.json
+dotnet pack src/models/Sdcb.SimdPaddleOCR.Models.DocumentOrientation -c Release -o build/nupkgs
+```
+
+在 Codex 的受限执行环境中,Edge 自身的沙箱可能阻止启动 GPU 子进程;本地受信任的 WASM 测试可给 run.ps1 加 `-BrowserNoSandbox`,普通环境保持默认。浏览器测试使用工作区中的独立临时 profile,不复用桌面浏览器 profile。输出 JSON 在 `bench-out/`,该目录不进入源码版本管理。
diff --git a/docs/resource-performance-validation.json b/docs/resource-performance-validation.json
new file mode 100644
index 0000000..92ed0ab
--- /dev/null
+++ b/docs/resource-performance-validation.json
@@ -0,0 +1,2077 @@
+{
+ "date": "2026-10-13",
+ "timezone": "Asia/Shanghai",
+ "hardware": {
+ "cpu": "12th Gen Intel(R) Core(TM) i7-12700",
+ "logical_processors": 20,
+ "gpu": "NVIDIA GeForce RTX 3060",
+ "driver": "610.88",
+ "total_gpu_memory_mi_b": 12288,
+ "total_system_memory_mi_b": 32509.42578125
+ },
+ "scope": "Single-request sustained workload; orientation classifier and document-orientation-enabled ChineseV6Tiny OCR.",
+ "aggregation": {
+ "latency": "call-count weighted arithmetic mean across two 20-second runs",
+ "process_cpu": "CPU-time / wall-time / 20 logical processors; wall-time weighted",
+ "sampled_resources": "arithmetic mean across all 250ms samples; peaks are sampled peaks",
+ "gpu_utilization_and_memory": "whole NVIDIA GPU, including desktop/other applications; baseline not subtracted"
+ },
+ "pipeline_speedup": 2.4106562250205408,
+ "source_sha256": {
+ "test/Sdcb.SimdPaddleOCR.GpuBench/ResourceBench.cs": "bd2a560f61d03762bfbfc6d8822aac42fbcf166d0059bbfdb3a373142d3fe032",
+ "models/doc_ori.onnx": "27ff8d574e1b9c00734f4b803cf505abc7dbf12c7a97d3f3917cf98f1d105424",
+ "examples/sample.jpg": "30c417c9f758a3b62718729f5a944f7d2e10cdd2bde0e8ce6785523ddb68ffe9"
+ },
+ "aggregate": [
+ {
+ "scope": "orientation",
+ "backend": "cpu",
+ "seconds": 40.0020243,
+ "calls": 14192,
+ "sample_count": 152,
+ "mean_ms": 2.8180496688274976,
+ "throughput_per_second": 354.78204536763906,
+ "process_cpu_mean_percent": 12.263051272632719,
+ "round_mean_ms": [
+ 2.3478121052013545,
+ 3.5237788898678253
+ ],
+ "round_median_ms": [
+ 2.2392,
+ 2.3645
+ ],
+ "round_p95_ms": [
+ 2.9407,
+ 8.8105
+ ],
+ "gpu_idle_mean_percent": 6.722222222222222,
+ "resources": {
+ "process_cpu_percent": {
+ "mean": 12.265794910885075,
+ "min": 3.5661018341745523,
+ "max": 19.989572454905442
+ },
+ "system_cpu_percent": {
+ "mean": 16.4050423678131,
+ "min": 8.55457227138643,
+ "max": 36.69467787114846
+ },
+ "process_working_set_mi_b": {
+ "mean": 108.84989206414474,
+ "min": 107.15234375,
+ "max": 114.85546875
+ },
+ "process_private_mi_b": {
+ "mean": 74.63052528782895,
+ "min": 72.890625,
+ "max": 80.72265625
+ },
+ "process_memory_percent": {
+ "mean": 0.33482563732923415,
+ "min": 0.3296039261690089,
+ "max": 0.3532989771115661
+ },
+ "system_memory_percent": {
+ "mean": 43.4078947368421,
+ "min": 43,
+ "max": 44
+ },
+ "gpu_utilization_percent": {
+ "mean": 7.822368421052632,
+ "min": 0,
+ "max": 84
+ },
+ "gpu_memory_mi_b": {
+ "mean": 2204.4965049342104,
+ "min": 2004.59765625,
+ "max": 2663.7578125
+ },
+ "gpu_memory_percent": {
+ "mean": 17.940238484165118,
+ "min": 16.313457489013672,
+ "max": 21.677716573079426
+ },
+ "gpu_clock_m_hz": {
+ "mean": 844.8223684210526,
+ "min": 210,
+ "max": 1852
+ },
+ "gpu_power_watts": {
+ "mean": 26.306546052631568,
+ "min": 16.655,
+ "max": 43.176
+ },
+ "gpu_temperature_c": {
+ "mean": 50.598684210526315,
+ "min": 49,
+ "max": 52
+ }
+ }
+ },
+ {
+ "scope": "orientation",
+ "backend": "vulkan",
+ "seconds": 40.001711900000004,
+ "calls": 38988,
+ "sample_count": 156,
+ "mean_ms": 1.0248077331486598,
+ "throughput_per_second": 974.6582870619594,
+ "process_cpu_mean_percent": 2.9608107847004415,
+ "round_mean_ms": [
+ 1.026786419943454,
+ 1.0228369477294859
+ ],
+ "round_median_ms": [
+ 0.9959,
+ 0.9864
+ ],
+ "round_p95_ms": [
+ 1.2056,
+ 1.1826
+ ],
+ "gpu_idle_mean_percent": 14.333333333333334,
+ "resources": {
+ "process_cpu_percent": {
+ "mean": 2.953245472676124,
+ "min": 0.925929583919345,
+ "max": 4.692192192192192
+ },
+ "system_cpu_percent": {
+ "mean": 5.769923438025953,
+ "min": 2.19435736677116,
+ "max": 18.674698795180724
+ },
+ "process_working_set_mi_b": {
+ "mean": 242.40584935897436,
+ "min": 236.421875,
+ "max": 247.79296875
+ },
+ "process_private_mi_b": {
+ "mean": 212.81628104967947,
+ "min": 205.90625,
+ "max": 219.5625
+ },
+ "process_memory_percent": {
+ "mean": 0.745647896059682,
+ "min": 0.7272410057035141,
+ "max": 0.7622188420593883
+ },
+ "system_memory_percent": {
+ "mean": 44.01282051282051,
+ "min": 44,
+ "max": 45
+ },
+ "gpu_utilization_percent": {
+ "mean": 38.92307692307692,
+ "min": 0,
+ "max": 72
+ },
+ "gpu_memory_mi_b": {
+ "mean": 2135.07574619391,
+ "min": 2012,
+ "max": 2265.61328125
+ },
+ "gpu_memory_percent": {
+ "mean": 17.37529090327076,
+ "min": 16.373697916666668,
+ "max": 18.43760808308919
+ },
+ "gpu_clock_m_hz": {
+ "mean": 1785.0128205128206,
+ "min": 337,
+ "max": 1920
+ },
+ "gpu_power_watts": {
+ "mean": 57.96642948717948,
+ "min": 17.27,
+ "max": 68.797
+ },
+ "gpu_temperature_c": {
+ "mean": 52.94230769230769,
+ "min": 48,
+ "max": 55
+ }
+ }
+ },
+ {
+ "scope": "pipeline",
+ "backend": "cpu",
+ "seconds": 40.0308931,
+ "calls": 1084,
+ "sample_count": 151,
+ "mean_ms": 36.92633265682657,
+ "throughput_per_second": 27.079086077147753,
+ "process_cpu_mean_percent": 40.168195747798585,
+ "round_mean_ms": [
+ 36.58746764168193,
+ 37.271508007448766
+ ],
+ "round_median_ms": [
+ 36.1764,
+ 36.2236
+ ],
+ "round_p95_ms": [
+ 39.6159,
+ 43.7038
+ ],
+ "gpu_idle_mean_percent": 3.7777777777777777,
+ "resources": {
+ "process_cpu_percent": {
+ "mean": 40.17250220709648,
+ "min": 28.764973592475833,
+ "max": 55.297223571461714
+ },
+ "system_cpu_percent": {
+ "mean": 44.54453873968004,
+ "min": 32.16374269005848,
+ "max": 70.53824362606233
+ },
+ "process_working_set_mi_b": {
+ "mean": 239.69329470198676,
+ "min": 202.515625,
+ "max": 285.18359375
+ },
+ "process_private_mi_b": {
+ "mean": 202.3320053807947,
+ "min": 165.3515625,
+ "max": 247.546875
+ },
+ "process_memory_percent": {
+ "mean": 0.7373039939703615,
+ "min": 0.6229443311693376,
+ "max": 0.8772335619489203
+ },
+ "system_memory_percent": {
+ "mean": 44.0728476821192,
+ "min": 43,
+ "max": 45
+ },
+ "gpu_utilization_percent": {
+ "mean": 11.509933774834437,
+ "min": 0,
+ "max": 63
+ },
+ "gpu_memory_mi_b": {
+ "mean": 2083.4854873758277,
+ "min": 1976.76953125,
+ "max": 2243.8203125
+ },
+ "gpu_memory_percent": {
+ "mean": 16.95544830221214,
+ "min": 16.086991628011067,
+ "max": 18.26025644938151
+ },
+ "gpu_clock_m_hz": {
+ "mean": 507.8079470198675,
+ "min": 210,
+ "max": 1852
+ },
+ "gpu_power_watts": {
+ "mean": 20.9351523178808,
+ "min": 16.442,
+ "max": 41.253
+ },
+ "gpu_temperature_c": {
+ "mean": 49.77483443708609,
+ "min": 47,
+ "max": 54
+ }
+ }
+ },
+ {
+ "scope": "pipeline",
+ "backend": "vulkan",
+ "seconds": 40.015837000000005,
+ "calls": 2612,
+ "sample_count": 156,
+ "mean_ms": 15.317958767228173,
+ "throughput_per_second": 65.27415632965517,
+ "process_cpu_mean_percent": 9.865234731938756,
+ "round_mean_ms": [
+ 15.196445668693,
+ 15.441347067901237
+ ],
+ "round_median_ms": [
+ 15.0144,
+ 15.0895
+ ],
+ "round_p95_ms": [
+ 16.322,
+ 16.6949
+ ],
+ "gpu_idle_mean_percent": 2.2777777777777777,
+ "resources": {
+ "process_cpu_percent": {
+ "mean": 9.854769773652448,
+ "min": 0.30834462450235806,
+ "max": 16.04765828620091
+ },
+ "system_cpu_percent": {
+ "mean": 12.489741967229302,
+ "min": 2.8213166144200628,
+ "max": 26.366559485530548
+ },
+ "process_working_set_mi_b": {
+ "mean": 332.3688151041667,
+ "min": 296.578125,
+ "max": 382.2890625
+ },
+ "process_private_mi_b": {
+ "mean": 399.9429336939103,
+ "min": 361.73046875,
+ "max": 450.98046875
+ },
+ "process_memory_percent": {
+ "mean": 1.0223767634058374,
+ "min": 0.912283492780279,
+ "max": 1.1759329896269266
+ },
+ "system_memory_percent": {
+ "mean": 44.02564102564103,
+ "min": 44,
+ "max": 45
+ },
+ "gpu_utilization_percent": {
+ "mean": 44.1025641025641,
+ "min": 40,
+ "max": 69
+ },
+ "gpu_memory_mi_b": {
+ "mean": 2170.8234174679487,
+ "min": 2095.125,
+ "max": 2264.8359375
+ },
+ "gpu_memory_percent": {
+ "mean": 17.666206196842026,
+ "min": 17.0501708984375,
+ "max": 18.43128204345703
+ },
+ "gpu_clock_m_hz": {
+ "mean": 1896.1666666666667,
+ "min": 397,
+ "max": 1935
+ },
+ "gpu_power_watts": {
+ "mean": 73.10153846153847,
+ "min": 18.098,
+ "max": 78.076
+ },
+ "gpu_temperature_c": {
+ "mean": 53.64102564102564,
+ "min": 44,
+ "max": 59
+ }
+ }
+ }
+ ],
+ "runs": [
+ {
+ "round": 1,
+ "source": "bench-out/resource-orientation-cpu.json",
+ "captured_at": "2026-10-13T16:06:02.1041294+08:00",
+ "backend": "Cpu",
+ "scope": "orientation",
+ "hardware": {
+ "cpu": "12th Gen Intel(R) Core(TM) i7-12700",
+ "logical_processors": 20,
+ "gpu": "NVIDIA GeForce RTX 3060",
+ "driver": "610.88",
+ "total_gpu_memory_mi_b": 12288,
+ "total_system_memory_mi_b": 32509.42578125
+ },
+ "input": {
+ "file": "examples/sample.jpg",
+ "width": 500,
+ "height": 500,
+ "clockwise_degrees": 90,
+ "format": "Bgr24",
+ "lines": null,
+ "text_hash": null,
+ "label": 90
+ },
+ "settings": {
+ "concurrency": 1,
+ "orientation_threads": 4,
+ "det_threads": 8,
+ "line_workers": 4,
+ "preprocess_workers": 4,
+ "gpu_ctc_threads": 4,
+ "warmup_calls": 1082,
+ "sample_interval_ms": 250,
+ "includes_image_decoding": false,
+ "includes_model_loading": false,
+ "includes_preprocessing": true,
+ "ctc_alignment": false,
+ "cpu_percent_denominator": "all logical processors",
+ "gpu_utilization_scope": "whole NVIDIA GPU, including other applications"
+ },
+ "measurement": {
+ "seconds": 20.0003667,
+ "calls": 8517,
+ "throughput_per_second": 425.8421921834063,
+ "mean_ms": 2.3478121052013545,
+ "median_ms": 2.2392,
+ "p95_ms": 2.9407,
+ "min_ms": 2.0353,
+ "max_ms": 19.6869,
+ "process_cpu_mean_percent": 13.261475600844857,
+ "process_cpu_core_equivalents": 2.6522951201689717
+ },
+ "resources": {
+ "sample_count": 76,
+ "process_cpu_percent": {
+ "mean": 13.229392883849192,
+ "min": 8.022868879168179,
+ "max": 19.989572454905442
+ },
+ "system_cpu_percent": {
+ "mean": 16.9034659444789,
+ "min": 8.55457227138643,
+ "max": 36.69467787114846
+ },
+ "process_working_set_mi_b": {
+ "mean": 108.81938733552632,
+ "min": 107.49609375,
+ "max": 114.85546875
+ },
+ "process_private_mi_b": {
+ "mean": 74.71247944078948,
+ "min": 72.890625,
+ "max": 80.72265625
+ },
+ "process_memory_percent": {
+ "mean": 0.33473180383976053,
+ "min": 0.3306613118094476,
+ "max": 0.3532989771115661
+ },
+ "system_memory_percent": {
+ "mean": 43.35526315789474,
+ "min": 43,
+ "max": 44
+ },
+ "gpu_utilization_percent": {
+ "mean": 10.210526315789474,
+ "min": 0,
+ "max": 84
+ },
+ "gpu_memory_mi_b": {
+ "mean": 2163.080386513158,
+ "min": 2004.59765625,
+ "max": 2201.56640625
+ },
+ "gpu_memory_percent": {
+ "mean": 17.603193249618787,
+ "min": 16.313457489013672,
+ "max": 17.916393280029297
+ },
+ "gpu_clock_m_hz": {
+ "mean": 714.9078947368421,
+ "min": 210,
+ "max": 1852
+ },
+ "gpu_power_watts": {
+ "mean": 24.30388157894736,
+ "min": 17.003,
+ "max": 42.815
+ },
+ "gpu_temperature_c": {
+ "mean": 50.21052631578947,
+ "min": 49,
+ "max": 52
+ }
+ },
+ "baseline": {
+ "sample_count": 9,
+ "process_cpu_percent": {
+ "mean": 0,
+ "min": 0,
+ "max": 0
+ },
+ "system_cpu_percent": {
+ "mean": 2.433571227144063,
+ "min": 1.5015015015015014,
+ "max": 3.216374269005848
+ },
+ "process_working_set_mi_b": {
+ "mean": 48.27256944444444,
+ "min": 48.171875,
+ "max": 48.60546875
+ },
+ "process_private_mi_b": {
+ "mean": 27.62152777777778,
+ "min": 27.53125,
+ "max": 28.03515625
+ },
+ "process_memory_percent": {
+ "mean": 0.14848791784038806,
+ "min": 0.1481781786123808,
+ "max": 0.1495119264088432
+ },
+ "system_memory_percent": {
+ "mean": 43,
+ "min": 43,
+ "max": 43
+ },
+ "gpu_utilization_percent": {
+ "mean": 8,
+ "min": 1,
+ "max": 17
+ },
+ "gpu_memory_mi_b": {
+ "mean": 1978.6731770833333,
+ "min": 1974.90234375,
+ "max": 1979.21484375
+ },
+ "gpu_memory_percent": {
+ "mean": 16.10248353746202,
+ "min": 16.071796417236328,
+ "max": 16.106891632080078
+ },
+ "gpu_clock_m_hz": {
+ "mean": 271.6666666666667,
+ "min": 210,
+ "max": 390
+ },
+ "gpu_power_watts": {
+ "mean": 18.278555555555556,
+ "min": 17.311,
+ "max": 20.778
+ },
+ "gpu_temperature_c": {
+ "mean": 49.77777777777778,
+ "min": 49,
+ "max": 50
+ }
+ },
+ "sessions_before": [
+ {
+ "name": "orientation",
+ "sessions": 1,
+ "session_types": "InferenceSession",
+ "gpu_alive": false
+ }
+ ],
+ "sessions_after": [
+ {
+ "name": "orientation",
+ "sessions": 1,
+ "session_types": "InferenceSession",
+ "gpu_alive": false
+ }
+ ]
+ },
+ {
+ "round": 2,
+ "source": "bench-out/resource-orientation-cpu-repeat.json",
+ "captured_at": "2026-10-13T16:11:40.6773855+08:00",
+ "backend": "Cpu",
+ "scope": "orientation",
+ "hardware": {
+ "cpu": "12th Gen Intel(R) Core(TM) i7-12700",
+ "logical_processors": 20,
+ "gpu": "NVIDIA GeForce RTX 3060",
+ "driver": "610.88",
+ "total_gpu_memory_mi_b": 12288,
+ "total_system_memory_mi_b": 32509.42578125
+ },
+ "input": {
+ "file": "examples/sample.jpg",
+ "width": 500,
+ "height": 500,
+ "clockwise_degrees": 90,
+ "format": "Bgr24",
+ "lines": null,
+ "text_hash": null,
+ "label": 90
+ },
+ "settings": {
+ "concurrency": 1,
+ "orientation_threads": 4,
+ "det_threads": 8,
+ "line_workers": 4,
+ "preprocess_workers": 4,
+ "gpu_ctc_threads": 4,
+ "warmup_seconds": 3.0058137,
+ "warmup_calls": 801,
+ "sample_interval_ms": 250,
+ "includes_image_decoding": false,
+ "includes_model_loading": false,
+ "includes_preprocessing": true,
+ "ctc_alignment": false,
+ "cpu_percent_denominator": "all logical processors",
+ "gpu_utilization_scope": "whole NVIDIA GPU, including other applications"
+ },
+ "measurement": {
+ "seconds": 20.0016576,
+ "calls": 5675,
+ "throughput_per_second": 283.72648474894396,
+ "mean_ms": 3.5237788898678253,
+ "median_ms": 2.3645,
+ "p95_ms": 8.8105,
+ "min_ms": 2.0729,
+ "max_ms": 111.5666,
+ "process_cpu_mean_percent": 11.264691382378226,
+ "process_cpu_core_equivalents": 2.2529382764756454
+ },
+ "resources": {
+ "sample_count": 76,
+ "process_cpu_percent": {
+ "mean": 11.302196937920959,
+ "min": 3.5661018341745523,
+ "max": 19.59835975652447
+ },
+ "system_cpu_percent": {
+ "mean": 15.9066187911473,
+ "min": 8.670520231213873,
+ "max": 24.705882352941178
+ },
+ "process_working_set_mi_b": {
+ "mean": 108.88039679276316,
+ "min": 107.15234375,
+ "max": 114.81640625
+ },
+ "process_private_mi_b": {
+ "mean": 74.54857113486842,
+ "min": 73.046875,
+ "max": 80.65234375
+ },
+ "process_memory_percent": {
+ "mean": 0.3349194708187078,
+ "min": 0.3296039261690089,
+ "max": 0.3531788196524253
+ },
+ "system_memory_percent": {
+ "mean": 43.46052631578947,
+ "min": 43,
+ "max": 44
+ },
+ "gpu_utilization_percent": {
+ "mean": 5.434210526315789,
+ "min": 0,
+ "max": 66
+ },
+ "gpu_memory_mi_b": {
+ "mean": 2245.9126233552633,
+ "min": 2053.82421875,
+ "max": 2663.7578125
+ },
+ "gpu_memory_percent": {
+ "mean": 18.27728371871145,
+ "min": 16.71406428019206,
+ "max": 21.677716573079426
+ },
+ "gpu_clock_m_hz": {
+ "mean": 974.7368421052631,
+ "min": 210,
+ "max": 1852
+ },
+ "gpu_power_watts": {
+ "mean": 28.309210526315777,
+ "min": 16.655,
+ "max": 43.176
+ },
+ "gpu_temperature_c": {
+ "mean": 50.98684210526316,
+ "min": 50,
+ "max": 52
+ }
+ },
+ "baseline": {
+ "sample_count": 9,
+ "process_cpu_percent": {
+ "mean": 0.09638878489398352,
+ "min": 0,
+ "max": 0.29283682700943225
+ },
+ "system_cpu_percent": {
+ "mean": 5.08671502384102,
+ "min": 1.8126888217522659,
+ "max": 11.965811965811966
+ },
+ "process_working_set_mi_b": {
+ "mean": 48.334201388888886,
+ "min": 48.2578125,
+ "max": 48.609375
+ },
+ "process_private_mi_b": {
+ "mean": 27.62543402777778,
+ "min": 27.55859375,
+ "max": 28.01171875
+ },
+ "process_memory_percent": {
+ "mean": 0.1486774996092546,
+ "min": 0.14844252502249047,
+ "max": 0.14952394215475728
+ },
+ "system_memory_percent": {
+ "mean": 44,
+ "min": 44,
+ "max": 44
+ },
+ "gpu_utilization_percent": {
+ "mean": 5.444444444444445,
+ "min": 0,
+ "max": 39
+ },
+ "gpu_memory_mi_b": {
+ "mean": 2288.7092013888887,
+ "min": 2235.7578125,
+ "max": 2296.0703125
+ },
+ "gpu_memory_percent": {
+ "mean": 18.625563162344474,
+ "min": 18.19464365641276,
+ "max": 18.685468037923176
+ },
+ "gpu_clock_m_hz": {
+ "mean": 1751.5555555555557,
+ "min": 540,
+ "max": 1920
+ },
+ "gpu_power_watts": {
+ "mean": 49.52633333333334,
+ "min": 41.235,
+ "max": 63.382
+ },
+ "gpu_temperature_c": {
+ "mean": 52.22222222222222,
+ "min": 52,
+ "max": 54
+ }
+ },
+ "sessions_before": [
+ {
+ "name": "orientation",
+ "sessions": 1,
+ "session_types": "InferenceSession",
+ "gpu_alive": false
+ }
+ ],
+ "sessions_after": [
+ {
+ "name": "orientation",
+ "sessions": 1,
+ "session_types": "InferenceSession",
+ "gpu_alive": false
+ }
+ ]
+ },
+ {
+ "round": 1,
+ "source": "bench-out/resource-orientation-vulkan.json",
+ "captured_at": "2026-10-13T16:06:33.2852936+08:00",
+ "backend": "Vulkan",
+ "scope": "orientation",
+ "hardware": {
+ "cpu": "12th Gen Intel(R) Core(TM) i7-12700",
+ "logical_processors": 20,
+ "gpu": "NVIDIA GeForce RTX 3060",
+ "driver": "610.88",
+ "total_gpu_memory_mi_b": 12288,
+ "total_system_memory_mi_b": 32509.42578125
+ },
+ "input": {
+ "file": "examples/sample.jpg",
+ "width": 500,
+ "height": 500,
+ "clockwise_degrees": 90,
+ "format": "Bgr24",
+ "lines": null,
+ "text_hash": null,
+ "label": 90
+ },
+ "settings": {
+ "concurrency": 1,
+ "orientation_threads": 4,
+ "det_threads": 8,
+ "line_workers": 4,
+ "preprocess_workers": 4,
+ "gpu_ctc_threads": 4,
+ "warmup_calls": 10,
+ "sample_interval_ms": 250,
+ "includes_image_decoding": false,
+ "includes_model_loading": false,
+ "includes_preprocessing": true,
+ "ctc_alignment": false,
+ "cpu_percent_denominator": "all logical processors",
+ "gpu_utilization_scope": "whole NVIDIA GPU, including other applications"
+ },
+ "measurement": {
+ "seconds": 20.0008173,
+ "calls": 19455,
+ "throughput_per_second": 972.7102501956257,
+ "mean_ms": 1.026786419943454,
+ "median_ms": 0.9959,
+ "p95_ms": 1.2056,
+ "min_ms": 0.9383,
+ "max_ms": 7.2499,
+ "process_cpu_mean_percent": 2.995971319632023,
+ "process_cpu_core_equivalents": 0.5991942639264046
+ },
+ "resources": {
+ "sample_count": 78,
+ "process_cpu_percent": {
+ "mean": 2.994194110997351,
+ "min": 0.925929583919345,
+ "max": 4.692192192192192
+ },
+ "system_cpu_percent": {
+ "mean": 5.506279676055362,
+ "min": 2.7950310559006213,
+ "max": 12.094395280235988
+ },
+ "process_working_set_mi_b": {
+ "mean": 242.33819110576923,
+ "min": 236.77734375,
+ "max": 247.6640625
+ },
+ "process_private_mi_b": {
+ "mean": 213.5411157852564,
+ "min": 208.02734375,
+ "max": 219.5625
+ },
+ "process_memory_percent": {
+ "mean": 0.7454397771785289,
+ "min": 0.728334438581695,
+ "max": 0.7618223224442238
+ },
+ "system_memory_percent": {
+ "mean": 44,
+ "min": 44,
+ "max": 44
+ },
+ "gpu_utilization_percent": {
+ "mean": 38.35897435897436,
+ "min": 0,
+ "max": 42
+ },
+ "gpu_memory_mi_b": {
+ "mean": 2051.108974358974,
+ "min": 2012,
+ "max": 2254.453125
+ },
+ "gpu_memory_percent": {
+ "mean": 16.6919675647703,
+ "min": 16.373697916666668,
+ "max": 18.346786499023438
+ },
+ "gpu_clock_m_hz": {
+ "mean": 1794.423076923077,
+ "min": 1687,
+ "max": 1920
+ },
+ "gpu_power_watts": {
+ "mean": 58.342089743589746,
+ "min": 31.393,
+ "max": 68.797
+ },
+ "gpu_temperature_c": {
+ "mean": 54.03846153846154,
+ "min": 51,
+ "max": 55
+ }
+ },
+ "baseline": {
+ "sample_count": 9,
+ "process_cpu_percent": {
+ "mean": 0.13172841708706265,
+ "min": 0,
+ "max": 0.2981555003535886
+ },
+ "system_cpu_percent": {
+ "mean": 4.81956418278924,
+ "min": 2.0833333333333335,
+ "max": 9.25925925925926
+ },
+ "process_working_set_mi_b": {
+ "mean": 48.162326388888886,
+ "min": 48.078125,
+ "max": 48.47265625
+ },
+ "process_private_mi_b": {
+ "mean": 27.49001736111111,
+ "min": 27.4453125,
+ "max": 27.83984375
+ },
+ "process_memory_percent": {
+ "mean": 0.14814880678903528,
+ "min": 0.147889800710443,
+ "max": 0.14910339104776463
+ },
+ "system_memory_percent": {
+ "mean": 43,
+ "min": 43,
+ "max": 43
+ },
+ "gpu_utilization_percent": {
+ "mean": 26.88888888888889,
+ "min": 1,
+ "max": 86
+ },
+ "gpu_memory_mi_b": {
+ "mean": 2197.19140625,
+ "min": 2194.59765625,
+ "max": 2201.56640625
+ },
+ "gpu_memory_percent": {
+ "mean": 17.88078943888346,
+ "min": 17.859681447347004,
+ "max": 17.916393280029297
+ },
+ "gpu_clock_m_hz": {
+ "mean": 490.77777777777777,
+ "min": 307,
+ "max": 525
+ },
+ "gpu_power_watts": {
+ "mean": 18.60322222222222,
+ "min": 17.505,
+ "max": 19.201
+ },
+ "gpu_temperature_c": {
+ "mean": 50,
+ "min": 50,
+ "max": 50
+ }
+ },
+ "sessions_before": [
+ {
+ "name": "orientation",
+ "sessions": 1,
+ "session_types": "GpuSession",
+ "gpu_alive": true
+ }
+ ],
+ "sessions_after": [
+ {
+ "name": "orientation",
+ "sessions": 1,
+ "session_types": "GpuSession",
+ "gpu_alive": true
+ }
+ ]
+ },
+ {
+ "round": 2,
+ "source": "bench-out/resource-orientation-vulkan-repeat.json",
+ "captured_at": "2026-10-13T16:11:14.7770276+08:00",
+ "backend": "Vulkan",
+ "scope": "orientation",
+ "hardware": {
+ "cpu": "12th Gen Intel(R) Core(TM) i7-12700",
+ "logical_processors": 20,
+ "gpu": "NVIDIA GeForce RTX 3060",
+ "driver": "610.88",
+ "total_gpu_memory_mi_b": 12288,
+ "total_system_memory_mi_b": 32509.42578125
+ },
+ "input": {
+ "file": "examples/sample.jpg",
+ "width": 500,
+ "height": 500,
+ "clockwise_degrees": 90,
+ "format": "Bgr24",
+ "lines": null,
+ "text_hash": null,
+ "label": 90
+ },
+ "settings": {
+ "concurrency": 1,
+ "orientation_threads": 4,
+ "det_threads": 8,
+ "line_workers": 4,
+ "preprocess_workers": 4,
+ "gpu_ctc_threads": 4,
+ "warmup_seconds": 8.4928061,
+ "warmup_calls": 10,
+ "sample_interval_ms": 250,
+ "includes_image_decoding": false,
+ "includes_model_loading": false,
+ "includes_preprocessing": true,
+ "ctc_alignment": false,
+ "cpu_percent_denominator": "all logical processors",
+ "gpu_utilization_scope": "whole NVIDIA GPU, including other applications"
+ },
+ "measurement": {
+ "seconds": 20.0008946,
+ "calls": 19533,
+ "throughput_per_second": 976.6063163994675,
+ "mean_ms": 1.0228369477294859,
+ "median_ms": 0.9864,
+ "p95_ms": 1.1826,
+ "min_ms": 0.9388,
+ "max_ms": 10.8798,
+ "process_cpu_mean_percent": 2.9256503856582494,
+ "process_cpu_core_equivalents": 0.5851300771316499
+ },
+ "resources": {
+ "sample_count": 78,
+ "process_cpu_percent": {
+ "mean": 2.912296834354897,
+ "min": 1.5401092753118077,
+ "max": 4.3231122772040855
+ },
+ "system_cpu_percent": {
+ "mean": 6.033567199996543,
+ "min": 2.19435736677116,
+ "max": 18.674698795180724
+ },
+ "process_working_set_mi_b": {
+ "mean": 242.47350761217947,
+ "min": 236.421875,
+ "max": 247.79296875
+ },
+ "process_private_mi_b": {
+ "mean": 212.09144631410257,
+ "min": 205.90625,
+ "max": 217.875
+ },
+ "process_memory_percent": {
+ "mean": 0.7458560149408349,
+ "min": 0.7272410057035141,
+ "max": 0.7622188420593883
+ },
+ "system_memory_percent": {
+ "mean": 44.02564102564103,
+ "min": 44,
+ "max": 45
+ },
+ "gpu_utilization_percent": {
+ "mean": 39.48717948717949,
+ "min": 37,
+ "max": 72
+ },
+ "gpu_memory_mi_b": {
+ "mean": 2219.042518028846,
+ "min": 2204.3671875,
+ "max": 2265.61328125
+ },
+ "gpu_memory_percent": {
+ "mean": 18.058614241771213,
+ "min": 17.939186096191406,
+ "max": 18.43760808308919
+ },
+ "gpu_clock_m_hz": {
+ "mean": 1775.6025641025642,
+ "min": 337,
+ "max": 1920
+ },
+ "gpu_power_watts": {
+ "mean": 57.59076923076922,
+ "min": 17.27,
+ "max": 68.146
+ },
+ "gpu_temperature_c": {
+ "mean": 51.84615384615385,
+ "min": 48,
+ "max": 53
+ }
+ },
+ "baseline": {
+ "sample_count": 9,
+ "process_cpu_percent": {
+ "mean": 0.1630997693670985,
+ "min": 0,
+ "max": 0.29437730862460515
+ },
+ "system_cpu_percent": {
+ "mean": 6.340518606527685,
+ "min": 1.2269938650306749,
+ "max": 13.00578034682081
+ },
+ "process_working_set_mi_b": {
+ "mean": 48.134982638888886,
+ "min": 48.01953125,
+ "max": 48.46484375
+ },
+ "process_private_mi_b": {
+ "mean": 27.501302083333332,
+ "min": 27.45703125,
+ "max": 27.84375
+ },
+ "process_memory_percent": {
+ "mean": 0.14806469656763677,
+ "min": 0.14770956452173187,
+ "max": 0.14907935955593649
+ },
+ "system_memory_percent": {
+ "mean": 44,
+ "min": 44,
+ "max": 44
+ },
+ "gpu_utilization_percent": {
+ "mean": 1.7777777777777777,
+ "min": 1,
+ "max": 2
+ },
+ "gpu_memory_mi_b": {
+ "mean": 2240.4661458333335,
+ "min": 2235.0703125,
+ "max": 2242.8203125
+ },
+ "gpu_memory_percent": {
+ "mean": 18.232960171169704,
+ "min": 18.189048767089844,
+ "max": 18.252118428548176
+ },
+ "gpu_clock_m_hz": {
+ "mean": 216.66666666666666,
+ "min": 210,
+ "max": 240
+ },
+ "gpu_power_watts": {
+ "mean": 17.967444444444446,
+ "min": 17.327,
+ "max": 18.175
+ },
+ "gpu_temperature_c": {
+ "mean": 47.666666666666664,
+ "min": 47,
+ "max": 48
+ }
+ },
+ "sessions_before": [
+ {
+ "name": "orientation",
+ "sessions": 1,
+ "session_types": "GpuSession",
+ "gpu_alive": true
+ }
+ ],
+ "sessions_after": [
+ {
+ "name": "orientation",
+ "sessions": 1,
+ "session_types": "GpuSession",
+ "gpu_alive": true
+ }
+ ]
+ },
+ {
+ "round": 1,
+ "source": "bench-out/resource-pipeline-cpu.json",
+ "captured_at": "2026-10-13T16:06:59.2506638+08:00",
+ "backend": "Cpu",
+ "scope": "pipeline",
+ "hardware": {
+ "cpu": "12th Gen Intel(R) Core(TM) i7-12700",
+ "logical_processors": 20,
+ "gpu": "NVIDIA GeForce RTX 3060",
+ "driver": "610.88",
+ "total_gpu_memory_mi_b": 12288,
+ "total_system_memory_mi_b": 32509.42578125
+ },
+ "input": {
+ "file": "examples/sample.jpg",
+ "width": 500,
+ "height": 500,
+ "clockwise_degrees": 90,
+ "format": "Bgr24",
+ "lines": 16,
+ "text_hash": "46d99468540b5eb7",
+ "label": 90
+ },
+ "settings": {
+ "concurrency": 1,
+ "orientation_threads": 4,
+ "det_threads": 8,
+ "line_workers": 4,
+ "preprocess_workers": 4,
+ "gpu_ctc_threads": 4,
+ "warmup_calls": 49,
+ "sample_interval_ms": 250,
+ "includes_image_decoding": false,
+ "includes_model_loading": false,
+ "includes_preprocessing": true,
+ "ctc_alignment": false,
+ "cpu_percent_denominator": "all logical processors",
+ "gpu_utilization_scope": "whole NVIDIA GPU, including other applications"
+ },
+ "measurement": {
+ "seconds": 20.0147195,
+ "calls": 547,
+ "throughput_per_second": 27.32988588723414,
+ "mean_ms": 36.58746764168193,
+ "median_ms": 36.1764,
+ "p95_ms": 39.6159,
+ "min_ms": 34.2731,
+ "max_ms": 58.4541,
+ "process_cpu_mean_percent": 39.833964448015365,
+ "process_cpu_core_equivalents": 7.966792889603074
+ },
+ "resources": {
+ "sample_count": 76,
+ "process_cpu_percent": {
+ "mean": 39.80546511536473,
+ "min": 28.764973592475833,
+ "max": 50.63384684451854
+ },
+ "system_cpu_percent": {
+ "mean": 43.47041403800776,
+ "min": 32.16374269005848,
+ "max": 57.18475073313783
+ },
+ "process_working_set_mi_b": {
+ "mean": 226.30658922697367,
+ "min": 202.515625,
+ "max": 244.4765625
+ },
+ "process_private_mi_b": {
+ "mean": 189.23643092105263,
+ "min": 165.3515625,
+ "max": 207.37890625
+ },
+ "process_memory_percent": {
+ "mean": 0.6961260735570952,
+ "min": 0.6229443311693376,
+ "max": 0.752017473778338
+ },
+ "system_memory_percent": {
+ "mean": 43.98684210526316,
+ "min": 43,
+ "max": 44
+ },
+ "gpu_utilization_percent": {
+ "mean": 10.381578947368421,
+ "min": 0,
+ "max": 48
+ },
+ "gpu_memory_mi_b": {
+ "mean": 1999.6083470394738,
+ "min": 1976.76953125,
+ "max": 2045.73828125
+ },
+ "gpu_memory_percent": {
+ "mean": 16.272854386714467,
+ "min": 16.086991628011067,
+ "max": 16.648260752360027
+ },
+ "gpu_clock_m_hz": {
+ "mean": 506.88157894736844,
+ "min": 210,
+ "max": 1852
+ },
+ "gpu_power_watts": {
+ "mean": 21.432328947368415,
+ "min": 16.712,
+ "max": 41.253
+ },
+ "gpu_temperature_c": {
+ "mean": 52.19736842105263,
+ "min": 52,
+ "max": 54
+ }
+ },
+ "baseline": {
+ "sample_count": 9,
+ "process_cpu_percent": {
+ "mean": 0.09811239524387647,
+ "min": 0,
+ "max": 0.296046112331926
+ },
+ "system_cpu_percent": {
+ "mean": 7.174158194551222,
+ "min": 2.840909090909091,
+ "max": 11.612903225806452
+ },
+ "process_working_set_mi_b": {
+ "mean": 48.11024305555556,
+ "min": 47.99609375,
+ "max": 48.43359375
+ },
+ "process_private_mi_b": {
+ "mean": 27.450520833333332,
+ "min": 27.40625,
+ "max": 27.796875
+ },
+ "process_memory_percent": {
+ "mean": 0.1479885968435143,
+ "min": 0.14763747004624742,
+ "max": 0.14898323358862386
+ },
+ "system_memory_percent": {
+ "mean": 43,
+ "min": 43,
+ "max": 43
+ },
+ "gpu_utilization_percent": {
+ "mean": 7.444444444444445,
+ "min": 1,
+ "max": 42
+ },
+ "gpu_memory_mi_b": {
+ "mean": 2004.61328125,
+ "min": 2000.70703125,
+ "max": 2007.73828125
+ },
+ "gpu_memory_percent": {
+ "mean": 16.31358464558919,
+ "min": 16.281795501708984,
+ "max": 16.33901596069336
+ },
+ "gpu_clock_m_hz": {
+ "mean": 1371.5555555555557,
+ "min": 390,
+ "max": 1830
+ },
+ "gpu_power_watts": {
+ "mean": 44.081444444444436,
+ "min": 29.458,
+ "max": 59.348
+ },
+ "gpu_temperature_c": {
+ "mean": 53.666666666666664,
+ "min": 52,
+ "max": 55
+ }
+ },
+ "sessions_before": [
+ {
+ "name": "documentOrientation",
+ "sessions": 1,
+ "session_types": "InferenceSession",
+ "gpu_alive": false
+ },
+ {
+ "name": "detector",
+ "sessions": 1,
+ "session_types": "InferenceSession",
+ "gpu_alive": false
+ },
+ {
+ "name": "classifier",
+ "sessions": 4,
+ "session_types": "InferenceSession",
+ "gpu_alive": false
+ },
+ {
+ "name": "recognizer",
+ "sessions": 4,
+ "session_types": "InferenceSession",
+ "gpu_alive": false
+ }
+ ],
+ "sessions_after": [
+ {
+ "name": "documentOrientation",
+ "sessions": 1,
+ "session_types": "InferenceSession",
+ "gpu_alive": false
+ },
+ {
+ "name": "detector",
+ "sessions": 1,
+ "session_types": "InferenceSession",
+ "gpu_alive": false
+ },
+ {
+ "name": "classifier",
+ "sessions": 4,
+ "session_types": "InferenceSession",
+ "gpu_alive": false
+ },
+ {
+ "name": "recognizer",
+ "sessions": 4,
+ "session_types": "InferenceSession",
+ "gpu_alive": false
+ }
+ ]
+ },
+ {
+ "round": 2,
+ "source": "bench-out/resource-pipeline-cpu-repeat.json",
+ "captured_at": "2026-10-13T16:10:43.447144+08:00",
+ "backend": "Cpu",
+ "scope": "pipeline",
+ "hardware": {
+ "cpu": "12th Gen Intel(R) Core(TM) i7-12700",
+ "logical_processors": 20,
+ "gpu": "NVIDIA GeForce RTX 3060",
+ "driver": "610.88",
+ "total_gpu_memory_mi_b": 12288,
+ "total_system_memory_mi_b": 32509.42578125
+ },
+ "input": {
+ "file": "examples/sample.jpg",
+ "width": 500,
+ "height": 500,
+ "clockwise_degrees": 90,
+ "format": "Bgr24",
+ "lines": 16,
+ "text_hash": "46d99468540b5eb7",
+ "label": 90
+ },
+ "settings": {
+ "concurrency": 1,
+ "orientation_threads": 4,
+ "det_threads": 8,
+ "line_workers": 4,
+ "preprocess_workers": 4,
+ "gpu_ctc_threads": 4,
+ "warmup_seconds": 3.0274701,
+ "warmup_calls": 63,
+ "sample_interval_ms": 250,
+ "includes_image_decoding": false,
+ "includes_model_loading": false,
+ "includes_preprocessing": true,
+ "ctc_alignment": false,
+ "cpu_percent_denominator": "all logical processors",
+ "gpu_utilization_scope": "whole NVIDIA GPU, including other applications"
+ },
+ "measurement": {
+ "seconds": 20.0161736,
+ "calls": 537,
+ "throughput_per_second": 26.828304486727674,
+ "mean_ms": 37.271508007448766,
+ "median_ms": 36.2236,
+ "p95_ms": 43.7038,
+ "min_ms": 34.0238,
+ "max_ms": 64.1824,
+ "process_cpu_mean_percent": 40.50240276693044,
+ "process_cpu_core_equivalents": 8.100480553386088
+ },
+ "resources": {
+ "sample_count": 75,
+ "process_cpu_percent": {
+ "mean": 40.54443312671798,
+ "min": 32.703581646898016,
+ "max": 55.297223571461714
+ },
+ "system_cpu_percent": {
+ "mean": 45.63298510404129,
+ "min": 35.27696793002915,
+ "max": 70.53824362606233
+ },
+ "process_working_set_mi_b": {
+ "mean": 253.25848958333333,
+ "min": 215.53125,
+ "max": 285.18359375
+ },
+ "process_private_mi_b": {
+ "mean": 215.6021875,
+ "min": 177.9140625,
+ "max": 247.546875
+ },
+ "process_memory_percent": {
+ "mean": 0.7790309533224714,
+ "min": 0.6629807965550376,
+ "max": 0.8772335619489203
+ },
+ "system_memory_percent": {
+ "mean": 44.16,
+ "min": 44,
+ "max": 45
+ },
+ "gpu_utilization_percent": {
+ "mean": 12.653333333333334,
+ "min": 0,
+ "max": 63
+ },
+ "gpu_memory_mi_b": {
+ "mean": 2168.480989583333,
+ "min": 2114.19921875,
+ "max": 2243.8203125
+ },
+ "gpu_memory_percent": {
+ "mean": 17.647143469916447,
+ "min": 17.20539728800456,
+ "max": 18.26025644938151
+ },
+ "gpu_clock_m_hz": {
+ "mean": 508.74666666666667,
+ "min": 210,
+ "max": 1852
+ },
+ "gpu_power_watts": {
+ "mean": 20.43134666666668,
+ "min": 16.442,
+ "max": 41.112
+ },
+ "gpu_temperature_c": {
+ "mean": 47.32,
+ "min": 47,
+ "max": 49
+ }
+ },
+ "baseline": {
+ "sample_count": 9,
+ "process_cpu_percent": {
+ "mean": 0.1314217179081594,
+ "min": 0,
+ "max": 0.2982927916043514
+ },
+ "system_cpu_percent": {
+ "mean": 3.033721460764764,
+ "min": 1.492537313432836,
+ "max": 4.678362573099415
+ },
+ "process_working_set_mi_b": {
+ "mean": 48.205729166666664,
+ "min": 48.09765625,
+ "max": 48.48046875
+ },
+ "process_private_mi_b": {
+ "mean": 27.504774305555557,
+ "min": 27.4609375,
+ "max": 27.84765625
+ },
+ "process_memory_percent": {
+ "mean": 0.14828231507696946,
+ "min": 0.14794987944001337,
+ "max": 0.1491274225395928
+ },
+ "system_memory_percent": {
+ "mean": 43,
+ "min": 43,
+ "max": 43
+ },
+ "gpu_utilization_percent": {
+ "mean": 0.1111111111111111,
+ "min": 0,
+ "max": 1
+ },
+ "gpu_memory_mi_b": {
+ "mean": 2121.0915798611113,
+ "min": 2114.69921875,
+ "max": 2123.73046875
+ },
+ "gpu_memory_percent": {
+ "mean": 17.261487466317636,
+ "min": 17.209466298421223,
+ "max": 17.282962799072266
+ },
+ "gpu_clock_m_hz": {
+ "mean": 1418.3333333333333,
+ "min": 390,
+ "max": 1920
+ },
+ "gpu_power_watts": {
+ "mean": 48.787444444444446,
+ "min": 33.33,
+ "max": 67.333
+ },
+ "gpu_temperature_c": {
+ "mean": 49.44444444444444,
+ "min": 48,
+ "max": 50
+ }
+ },
+ "sessions_before": [
+ {
+ "name": "documentOrientation",
+ "sessions": 1,
+ "session_types": "InferenceSession",
+ "gpu_alive": false
+ },
+ {
+ "name": "detector",
+ "sessions": 1,
+ "session_types": "InferenceSession",
+ "gpu_alive": false
+ },
+ {
+ "name": "classifier",
+ "sessions": 4,
+ "session_types": "InferenceSession",
+ "gpu_alive": false
+ },
+ {
+ "name": "recognizer",
+ "sessions": 4,
+ "session_types": "InferenceSession",
+ "gpu_alive": false
+ }
+ ],
+ "sessions_after": [
+ {
+ "name": "documentOrientation",
+ "sessions": 1,
+ "session_types": "InferenceSession",
+ "gpu_alive": false
+ },
+ {
+ "name": "detector",
+ "sessions": 1,
+ "session_types": "InferenceSession",
+ "gpu_alive": false
+ },
+ {
+ "name": "classifier",
+ "sessions": 4,
+ "session_types": "InferenceSession",
+ "gpu_alive": false
+ },
+ {
+ "name": "recognizer",
+ "sessions": 4,
+ "session_types": "InferenceSession",
+ "gpu_alive": false
+ }
+ ]
+ },
+ {
+ "round": 1,
+ "source": "bench-out/resource-pipeline-vulkan.json",
+ "captured_at": "2026-10-13T16:07:31.427368+08:00",
+ "backend": "Vulkan",
+ "scope": "pipeline",
+ "hardware": {
+ "cpu": "12th Gen Intel(R) Core(TM) i7-12700",
+ "logical_processors": 20,
+ "gpu": "NVIDIA GeForce RTX 3060",
+ "driver": "610.88",
+ "total_gpu_memory_mi_b": 12288,
+ "total_system_memory_mi_b": 32509.42578125
+ },
+ "input": {
+ "file": "examples/sample.jpg",
+ "width": 500,
+ "height": 500,
+ "clockwise_degrees": 90,
+ "format": "Bgr24",
+ "lines": 16,
+ "text_hash": "46d99468540b5eb7",
+ "label": 90
+ },
+ "settings": {
+ "concurrency": 1,
+ "orientation_threads": 4,
+ "det_threads": 8,
+ "line_workers": 4,
+ "preprocess_workers": 4,
+ "gpu_ctc_threads": 4,
+ "warmup_calls": 10,
+ "sample_interval_ms": 250,
+ "includes_image_decoding": false,
+ "includes_model_loading": false,
+ "includes_preprocessing": true,
+ "ctc_alignment": false,
+ "cpu_percent_denominator": "all logical processors",
+ "gpu_utilization_scope": "whole NVIDIA GPU, including other applications"
+ },
+ "measurement": {
+ "seconds": 20.0008348,
+ "calls": 1316,
+ "throughput_per_second": 65.79725362263379,
+ "mean_ms": 15.196445668693,
+ "median_ms": 15.0144,
+ "p95_ms": 16.322,
+ "min_ms": 14.3071,
+ "max_ms": 32.5017,
+ "process_cpu_mean_percent": 10.042549573980782,
+ "process_cpu_core_equivalents": 2.0085099147961563
+ },
+ "resources": {
+ "sample_count": 78,
+ "process_cpu_percent": {
+ "mean": 10.036428922200598,
+ "min": 4.307367439413309,
+ "max": 14.774336780021974
+ },
+ "system_cpu_percent": {
+ "mean": 12.263515912129236,
+ "min": 4.761904761904762,
+ "max": 23.006134969325153
+ },
+ "process_working_set_mi_b": {
+ "mean": 332.1708733974359,
+ "min": 296.69140625,
+ "max": 382.2890625
+ },
+ "process_private_mi_b": {
+ "mean": 401.3563201121795,
+ "min": 364.9375,
+ "max": 450.98046875
+ },
+ "process_memory_percent": {
+ "mean": 1.0217678885888455,
+ "min": 0.9126319494117872,
+ "max": 1.1759329896269266
+ },
+ "system_memory_percent": {
+ "mean": 44,
+ "min": 44,
+ "max": 44
+ },
+ "gpu_utilization_percent": {
+ "mean": 43.84615384615385,
+ "min": 40,
+ "max": 69
+ },
+ "gpu_memory_mi_b": {
+ "mean": 2102.719150641026,
+ "min": 2095.125,
+ "max": 2108.40625
+ },
+ "gpu_memory_percent": {
+ "mean": 17.111972254565636,
+ "min": 17.0501708984375,
+ "max": 17.158253987630207
+ },
+ "gpu_clock_m_hz": {
+ "mean": 1905.6666666666667,
+ "min": 397,
+ "max": 1935
+ },
+ "gpu_power_watts": {
+ "mean": 74.22694871794873,
+ "min": 18.098,
+ "max": 78.076
+ },
+ "gpu_temperature_c": {
+ "mean": 57.294871794871796,
+ "min": 52,
+ "max": 59
+ }
+ },
+ "baseline": {
+ "sample_count": 9,
+ "process_cpu_percent": {
+ "mean": 0.2293433649043616,
+ "min": 0,
+ "max": 0.5874585771208101
+ },
+ "system_cpu_percent": {
+ "mean": 4.891027774793144,
+ "min": 2.005730659025788,
+ "max": 13.753581661891117
+ },
+ "process_working_set_mi_b": {
+ "mean": 48.29730902777778,
+ "min": 48.2265625,
+ "max": 48.56640625
+ },
+ "process_private_mi_b": {
+ "mean": 27.55685763888889,
+ "min": 27.4921875,
+ "max": 27.953125
+ },
+ "process_memory_percent": {
+ "mean": 0.14856401756451054,
+ "min": 0.14834639905517788,
+ "max": 0.14939176894970244
+ },
+ "system_memory_percent": {
+ "mean": 43,
+ "min": 43,
+ "max": 43
+ },
+ "gpu_utilization_percent": {
+ "mean": 2.4444444444444446,
+ "min": 1,
+ "max": 9
+ },
+ "gpu_memory_mi_b": {
+ "mean": 1981.4778645833333,
+ "min": 1976.83203125,
+ "max": 1983.80078125
+ },
+ "gpu_memory_percent": {
+ "mean": 16.125308142768013,
+ "min": 16.087500254313152,
+ "max": 16.14421208699544
+ },
+ "gpu_clock_m_hz": {
+ "mean": 210,
+ "min": 210,
+ "max": 210
+ },
+ "gpu_power_watts": {
+ "mean": 17.053444444444448,
+ "min": 16.76,
+ "max": 17.208
+ },
+ "gpu_temperature_c": {
+ "mean": 51.77777777777778,
+ "min": 51,
+ "max": 52
+ }
+ },
+ "sessions_before": [
+ {
+ "name": "documentOrientation",
+ "sessions": 1,
+ "session_types": "GpuSession",
+ "gpu_alive": true
+ },
+ {
+ "name": "detector",
+ "sessions": 1,
+ "session_types": "GpuSession",
+ "gpu_alive": true
+ },
+ {
+ "name": "classifier",
+ "sessions": 1,
+ "session_types": "GpuSession",
+ "gpu_alive": true
+ },
+ {
+ "name": "recognizer",
+ "sessions": 1,
+ "session_types": "GpuSession",
+ "gpu_alive": true
+ }
+ ],
+ "sessions_after": [
+ {
+ "name": "documentOrientation",
+ "sessions": 1,
+ "session_types": "GpuSession",
+ "gpu_alive": true
+ },
+ {
+ "name": "detector",
+ "sessions": 1,
+ "session_types": "GpuSession",
+ "gpu_alive": true
+ },
+ {
+ "name": "classifier",
+ "sessions": 1,
+ "session_types": "GpuSession",
+ "gpu_alive": true
+ },
+ {
+ "name": "recognizer",
+ "sessions": 1,
+ "session_types": "GpuSession",
+ "gpu_alive": true
+ }
+ ]
+ },
+ {
+ "round": 2,
+ "source": "bench-out/resource-pipeline-vulkan-repeat.json",
+ "captured_at": "2026-10-13T16:10:17.4580061+08:00",
+ "backend": "Vulkan",
+ "scope": "pipeline",
+ "hardware": {
+ "cpu": "12th Gen Intel(R) Core(TM) i7-12700",
+ "logical_processors": 20,
+ "gpu": "NVIDIA GeForce RTX 3060",
+ "driver": "610.88",
+ "total_gpu_memory_mi_b": 12288,
+ "total_system_memory_mi_b": 32509.42578125
+ },
+ "input": {
+ "file": "examples/sample.jpg",
+ "width": 500,
+ "height": 500,
+ "clockwise_degrees": 90,
+ "format": "Bgr24",
+ "lines": 16,
+ "text_hash": "46d99468540b5eb7",
+ "label": 90
+ },
+ "settings": {
+ "concurrency": 1,
+ "orientation_threads": 4,
+ "det_threads": 8,
+ "line_workers": 4,
+ "preprocess_workers": 4,
+ "gpu_ctc_threads": 4,
+ "warmup_seconds": 9.9804425,
+ "warmup_calls": 10,
+ "sample_interval_ms": 250,
+ "includes_image_decoding": false,
+ "includes_model_loading": false,
+ "includes_preprocessing": true,
+ "ctc_alignment": false,
+ "cpu_percent_denominator": "all logical processors",
+ "gpu_utilization_scope": "whole NVIDIA GPU, including other applications"
+ },
+ "measurement": {
+ "seconds": 20.0150022,
+ "calls": 1296,
+ "throughput_per_second": 64.75142930536374,
+ "mean_ms": 15.441347067901237,
+ "median_ms": 15.0895,
+ "p95_ms": 16.6949,
+ "min_ms": 14.2654,
+ "max_ms": 45.1787,
+ "process_cpu_mean_percent": 9.688045400264807,
+ "process_cpu_core_equivalents": 1.9376090800529613
+ },
+ "resources": {
+ "sample_count": 78,
+ "process_cpu_percent": {
+ "mean": 9.673110625104297,
+ "min": 0.30834462450235806,
+ "max": 16.04765828620091
+ },
+ "system_cpu_percent": {
+ "mean": 12.715968022329367,
+ "min": 2.8213166144200628,
+ "max": 26.366559485530548
+ },
+ "process_working_set_mi_b": {
+ "mean": 332.56675681089746,
+ "min": 296.578125,
+ "max": 382.1640625
+ },
+ "process_private_mi_b": {
+ "mean": 398.52954727564105,
+ "min": 361.73046875,
+ "max": 448.16796875
+ },
+ "process_memory_percent": {
+ "mean": 1.0229856382228295,
+ "min": 0.912283492780279,
+ "max": 1.1755484857576763
+ },
+ "system_memory_percent": {
+ "mean": 44.05128205128205,
+ "min": 44,
+ "max": 45
+ },
+ "gpu_utilization_percent": {
+ "mean": 44.35897435897436,
+ "min": 42,
+ "max": 57
+ },
+ "gpu_memory_mi_b": {
+ "mean": 2238.9276842948716,
+ "min": 2227.3359375,
+ "max": 2264.8359375
+ },
+ "gpu_memory_percent": {
+ "mean": 18.220440139118416,
+ "min": 18.12610626220703,
+ "max": 18.43128204345703
+ },
+ "gpu_clock_m_hz": {
+ "mean": 1886.6666666666667,
+ "min": 585,
+ "max": 1927
+ },
+ "gpu_power_watts": {
+ "mean": 71.97612820512819,
+ "min": 18.213,
+ "max": 76.297
+ },
+ "gpu_temperature_c": {
+ "mean": 49.98717948717949,
+ "min": 44,
+ "max": 52
+ }
+ },
+ "baseline": {
+ "sample_count": 9,
+ "process_cpu_percent": {
+ "mean": 0.09862217113702562,
+ "min": 0,
+ "max": 0.3063584925711251
+ },
+ "system_cpu_percent": {
+ "mean": 10.496129166113896,
+ "min": 5.982905982905983,
+ "max": 17.10144927536232
+ },
+ "process_working_set_mi_b": {
+ "mean": 48.1953125,
+ "min": 48.0859375,
+ "max": 48.53515625
+ },
+ "process_private_mi_b": {
+ "mean": 27.53949652777778,
+ "min": 27.375,
+ "max": 28.125
+ },
+ "process_memory_percent": {
+ "mean": 0.14825027308786526,
+ "min": 0.14791383220227114,
+ "max": 0.14929564298238984
+ },
+ "system_memory_percent": {
+ "mean": 44,
+ "min": 44,
+ "max": 44
+ },
+ "gpu_utilization_percent": {
+ "mean": 2.111111111111111,
+ "min": 0,
+ "max": 15
+ },
+ "gpu_memory_mi_b": {
+ "mean": 2092.10546875,
+ "min": 2086.32421875,
+ "max": 2096.54296875
+ },
+ "gpu_memory_percent": {
+ "mean": 17.025597890218094,
+ "min": 16.97854995727539,
+ "max": 17.061710357666016
+ },
+ "gpu_clock_m_hz": {
+ "mean": 672.2222222222222,
+ "min": 232,
+ "max": 1852
+ },
+ "gpu_power_watts": {
+ "mean": 31.35966666666667,
+ "min": 18.269,
+ "max": 40.57
+ },
+ "gpu_temperature_c": {
+ "mean": 44.22222222222222,
+ "min": 44,
+ "max": 45
+ }
+ },
+ "sessions_before": [
+ {
+ "name": "documentOrientation",
+ "sessions": 1,
+ "session_types": "GpuSession",
+ "gpu_alive": true
+ },
+ {
+ "name": "detector",
+ "sessions": 1,
+ "session_types": "GpuSession",
+ "gpu_alive": true
+ },
+ {
+ "name": "classifier",
+ "sessions": 1,
+ "session_types": "GpuSession",
+ "gpu_alive": true
+ },
+ {
+ "name": "recognizer",
+ "sessions": 1,
+ "session_types": "GpuSession",
+ "gpu_alive": true
+ }
+ ],
+ "sessions_after": [
+ {
+ "name": "documentOrientation",
+ "sessions": 1,
+ "session_types": "GpuSession",
+ "gpu_alive": true
+ },
+ {
+ "name": "detector",
+ "sessions": 1,
+ "session_types": "GpuSession",
+ "gpu_alive": true
+ },
+ {
+ "name": "classifier",
+ "sessions": 1,
+ "session_types": "GpuSession",
+ "gpu_alive": true
+ },
+ {
+ "name": "recognizer",
+ "sessions": 1,
+ "session_types": "GpuSession",
+ "gpu_alive": true
+ }
+ ]
+ }
+ ]
+}
diff --git a/docs/resource-performance.md b/docs/resource-performance.md
new file mode 100644
index 0000000..fb192db
--- /dev/null
+++ b/docs/resource-performance.md
@@ -0,0 +1,63 @@
+# 本机 CPU / GPU 耗时与资源使用率
+
+2026-10-13(Asia/Shanghai),i7-12700 / 20 逻辑处理器,32 GB 内存,RTX 3060 / 12 GiB 显存,驱动 610.88,.NET 10 Release。
+
+使用 `examples/sample.jpg`(500×500,16 行),预先顺时针旋转 90°;单请求连续运行。模型为 `PP-LCNet_x1_0_doc_ori` 与 `ChineseV6TinyModels.Default`。四个模式各运行两轮,每轮预热至少 3 秒且不少于 10 次调用,再计时 20 秒。第二轮反向执行各模式,避免只用一个顺序作比较。
+
+计时包含预处理;完整 OCR 包含纸张方向、整页旋转、DET、文本行 CLS、REC 和后处理。模型加载、首次 shader 编译、文件读取与 JPEG 解码不计时;未请求 CTC 对齐。方向单独测试只运行 Classify,不旋转整页。所有 GPU 阶段在预热后及测试结束均检查实际 Vulkan 会话和 GpuAlive,CPU 模式则检查全部为 InferenceSession。GPU 模式保留 CPU 预处理、后处理与 CTC 投影。
+
+固定预算:方向 4 线程、DET 8 线程、行 worker 4、预处理 worker 4、GPU CTC 4;请求并发为 1。
+
+## 耗时
+
+平均耗时按调用次数汇总两轮;没有将两轮 P95 直接平均。
+
+| 模式 | 合计平均 ms | 两轮平均 ms | 两轮 P95 ms | 40 秒调用数 | 吞吐 次/秒 |
+| --- | ---: | ---: | ---: | ---: | ---: |
+| 纸张方向识别 / CPU | 2.82 | 2.35 / 3.52 | 2.94 / 8.81 | 14192 | 354.8 |
+| 纸张方向识别 / Vulkan | 1.02 | 1.03 / 1.02 | 1.21 / 1.18 | 38988 | 974.7 |
+| 完整 OCR(含纸张方向) / CPU | 36.93 | 36.59 / 37.27 | 39.62 / 43.70 | 1084 | 27.1 |
+| 完整 OCR(含纸张方向) / Vulkan | 15.32 | 15.20 / 15.44 | 16.32 / 16.69 | 2612 | 65.3 |
+
+完整 OCR 的 GPU 约快 **2.41 倍**,两轮结果稳定。CPU 方向测试第二轮长尾增多(P95 8.81 ms),因此保留两轮结果并按所有调用汇总,不只报告较快的一轮。
+
+## CPU / GPU 使用率
+
+CPU 进程占用按整机 20 个逻辑处理器归一化,100% 表示全部逻辑处理器满载;一个逻辑处理器满载约 5%。CPU 与 GPU 均给出平均 / 采样峰值。GPU 是整卡 NVML 读数,包含桌面与其他应用;基线仅供参考,没有直接从测量值中扣除。
+
+| 模式 | 进程 CPU 平均 / 峰值 % | 整机 CPU 平均 / 峰值 % | 整卡 GPU 平均 / 峰值 % | 测前 GPU 基线 % |
+| --- | ---: | ---: | ---: | ---: |
+| 纸张方向识别 / CPU | 12.3 / 20.0 | 16.4 / 36.7 | 7.8 / 84.0 | 6.7 |
+| 纸张方向识别 / Vulkan | 3.0 / 4.7 | 5.8 / 18.7 | 38.9 / 72.0 | 14.3 |
+| 完整 OCR(含纸张方向) / CPU | 40.2 / 55.3 | 44.5 / 70.5 | 11.5 / 63.0 | 3.8 |
+| 完整 OCR(含纸张方向) / Vulkan | 9.9 / 16.0 | 12.5 / 26.4 | 44.1 / 69.0 | 2.3 |
+
+CPU 模式没有执行 GPU 推理;表中对应的 GPU 占用来自整卡后台活动。
+
+## 内存
+
+进程内存是物理工作集(Working Set),使用率分母为系统报告的总物理内存 32509.4 MiB;私有提交内存单独列出。整机 RAM 与整卡显存包含其他进程,显存分母为 12288 MiB。GPU 内存这里不是 OCR 进程独占显存。采样间隔 250 ms,峰值为采样峰值。
+
+| 模式 | 工作集平均 / 峰值 MiB | 工作集占物理内存 % | 私有提交平均 / 峰值 MiB | 整机 RAM 平均 % | 整卡显存平均 / 峰值 MiB | 整卡显存平均 % |
+| --- | ---: | ---: | ---: | ---: | ---: | ---: |
+| 纸张方向识别 / CPU | 108.8 / 114.9 | 0.33 | 74.6 / 80.7 | 43.4 | 2204.5 / 2663.8 | 17.9 |
+| 纸张方向识别 / Vulkan | 242.4 / 247.8 | 0.75 | 212.8 / 219.6 | 44.0 | 2135.1 / 2265.6 | 17.4 |
+| 完整 OCR(含纸张方向) / CPU | 239.7 / 285.2 | 0.74 | 202.3 / 247.5 | 44.1 | 2083.5 / 2243.8 | 17.0 |
+| 完整 OCR(含纸张方向) / Vulkan | 332.4 / 382.3 | 1.02 | 399.9 / 451.0 | 44.0 | 2170.8 / 2264.8 | 17.7 |
+
+## 正确性与复现
+
+四次完整 OCR 测试均识别 16 行,文本 hash 为 `46d99468540b5eb7`;四次方向独立测试与完整 OCR 的方向标签均为 90°。全部会话的实际后端与请求后端一致,Vulkan 未回落。
+
+[数据快照](resource-performance-validation.json) 保留各轮测量值、硬件、预算、平均/峰值、基线和会话检查。完整逐点使用率/内存采样位于 `bench-out/resource-*.json`,该输出目录不进入 Git。
+
+```powershell
+dotnet build test/Sdcb.SimdPaddleOCR.GpuBench -c Release
+$perfDll = 'test/Sdcb.SimdPaddleOCR.GpuBench/bin/Release/net10.0/Sdcb.SimdPaddleOCR.GpuBench.dll'
+dotnet $perfDll --resource-perf --backend cpu --scope orientation --seconds 20 --out bench-out/resource-orientation-cpu.json
+dotnet $perfDll --resource-perf --backend vulkan --scope orientation --seconds 20 --out bench-out/resource-orientation-vulkan.json
+dotnet $perfDll --resource-perf --backend cpu --scope pipeline --seconds 20 --out bench-out/resource-pipeline-cpu.json
+dotnet $perfDll --resource-perf --backend vulkan --scope pipeline --seconds 20 --out bench-out/resource-pipeline-vulkan.json
+```
+
+资源采样仅在 Windows/NVIDIA 开发测试工具中使用 Win32 与 NVML,未增加生产库依赖。
diff --git a/global.json b/global.json
new file mode 100644
index 0000000..d46d21e
--- /dev/null
+++ b/global.json
@@ -0,0 +1,7 @@
+{
+ "sdk": {
+ "version": "10.0.100",
+ "rollForward": "latestFeature",
+ "allowPrerelease": false
+ }
+}
diff --git a/models/doc_ori.onnx b/models/doc_ori.onnx
new file mode 100644
index 0000000..77fe2d2
Binary files /dev/null and b/models/doc_ori.onnx differ
diff --git a/models/orientation-manifest.json b/models/orientation-manifest.json
new file mode 100644
index 0000000..de5427c
--- /dev/null
+++ b/models/orientation-manifest.json
@@ -0,0 +1,522 @@
+{
+ "tools": {
+ "paddlepaddle": "3.1.1",
+ "paddle2onnx": "2.1.0",
+ "onnx": "1.17.0",
+ "onnxsim": "0.8.1",
+ "onnxruntime": "1.24.3",
+ "opencv-python-headless": "4.13.0.92"
+ },
+ "sample": {
+ "path": "examples\\sample.jpg",
+ "sha256": "30c417c9f758a3b62718729f5a944f7d2e10cdd2bde0e8ce6785523ddb68ffe9"
+ },
+ "models": {
+ "PP-LCNet_x1_0_doc_ori": {
+ "repository": "https://huggingface.co/PaddlePaddle/PP-LCNet_x1_0_doc_ori",
+ "revision": "d3b95a6dff5fe8a94f2748e12b61cb26818a0df8",
+ "source_files": {
+ "inference.json": {
+ "sha256": "3580978602f309c3554508dd85d4fe09b73a7e0d80d7f9e63258f5b72c390c69",
+ "bytes": 104441
+ },
+ "inference.pdiparams": {
+ "sha256": "e8d6e7c5d264507e40e58a655779059d616b20d7441ea22047d829eb3931989c",
+ "bytes": 6754166
+ },
+ "inference.yml": {
+ "sha256": "9e195eb729a8173588cd0e8a852c8b373aa606e79e77b4ac7d8346f5426caf26",
+ "bytes": 766
+ },
+ "config.json": {
+ "sha256": "e49f6be2fe4034232b05fb80d983a7b9bde4592d4a52812129c9a348057d3147",
+ "bytes": 2561
+ },
+ "README.md": {
+ "sha256": "e38ea9c16eba0e14e12216418085e8974481d7c0fc3aed4e7df5cf1869c2c97a",
+ "bytes": 6850
+ }
+ },
+ "output": "doc_ori.onnx",
+ "sha256": "27ff8d574e1b9c00734f4b803cf505abc7dbf12c7a97d3f3917cf98f1d105424",
+ "raw_nodes": 310,
+ "simplified_nodes": 113,
+ "operators": {
+ "Constant": 12,
+ "Conv": 32,
+ "HardSigmoid": 30,
+ "Mul": 31,
+ "GlobalAveragePool": 3,
+ "Relu": 2,
+ "Reshape": 1,
+ "Gemm": 1,
+ "Softmax": 1
+ },
+ "comparisons": [
+ {
+ "case": "random-0",
+ "paddle_raw_max_abs": 1.1920928955078125e-07,
+ "paddle_sim_max_abs": 1.7881393432617188e-07,
+ "raw_sim_max_abs": 1.1920928955078125e-07,
+ "label": 0,
+ "scores": [
+ [
+ 0.28142207860946655,
+ 0.2498965859413147,
+ 0.2648017704486847,
+ 0.20387960970401764
+ ]
+ ]
+ },
+ {
+ "case": "random-1",
+ "paddle_raw_max_abs": 1.1920928955078125e-07,
+ "paddle_sim_max_abs": 2.682209014892578e-07,
+ "raw_sim_max_abs": 1.9371509552001953e-07,
+ "label": 0,
+ "scores": [
+ [
+ 0.30578505992889404,
+ 0.2565608024597168,
+ 0.26518818736076355,
+ 0.17246584594249725
+ ]
+ ]
+ },
+ {
+ "case": "random-2",
+ "paddle_raw_max_abs": 8.940696716308594e-08,
+ "paddle_sim_max_abs": 1.1920928955078125e-07,
+ "raw_sim_max_abs": 1.1920928955078125e-07,
+ "label": 0,
+ "scores": [
+ [
+ 0.29580143094062805,
+ 0.22903485596179962,
+ 0.2948785722255707,
+ 0.18028517067432404
+ ]
+ ]
+ },
+ {
+ "case": "random-3",
+ "paddle_raw_max_abs": 1.4901161193847656e-07,
+ "paddle_sim_max_abs": 2.384185791015625e-07,
+ "raw_sim_max_abs": 1.7881393432617188e-07,
+ "label": 0,
+ "scores": [
+ [
+ 0.32813024520874023,
+ 0.25631988048553467,
+ 0.25225743651390076,
+ 0.16329240798950195
+ ]
+ ]
+ },
+ {
+ "case": "random-4",
+ "paddle_raw_max_abs": 1.1920928955078125e-07,
+ "paddle_sim_max_abs": 1.1920928955078125e-07,
+ "raw_sim_max_abs": 5.960464477539063e-08,
+ "label": 0,
+ "scores": [
+ [
+ 0.28659605979919434,
+ 0.22201256453990936,
+ 0.28452473878860474,
+ 0.20686659216880798
+ ]
+ ]
+ },
+ {
+ "case": "random-5",
+ "paddle_raw_max_abs": 3.2782554626464844e-07,
+ "paddle_sim_max_abs": 3.5762786865234375e-07,
+ "raw_sim_max_abs": 1.1920928955078125e-07,
+ "label": 0,
+ "scores": [
+ [
+ 0.3295997381210327,
+ 0.1924605667591095,
+ 0.27435213327407837,
+ 0.2035875767469406
+ ]
+ ]
+ },
+ {
+ "case": "random-6",
+ "paddle_raw_max_abs": 1.1920928955078125e-07,
+ "paddle_sim_max_abs": 2.682209014892578e-07,
+ "raw_sim_max_abs": 1.7881393432617188e-07,
+ "label": 0,
+ "scores": [
+ [
+ 0.320997953414917,
+ 0.22951894998550415,
+ 0.25409746170043945,
+ 0.1953856498003006
+ ]
+ ]
+ },
+ {
+ "case": "random-7",
+ "paddle_raw_max_abs": 1.341104507446289e-07,
+ "paddle_sim_max_abs": 2.384185791015625e-07,
+ "raw_sim_max_abs": 2.086162567138672e-07,
+ "label": 0,
+ "scores": [
+ [
+ 0.29709500074386597,
+ 0.24680718779563904,
+ 0.29533252120018005,
+ 0.16076526045799255
+ ]
+ ]
+ },
+ {
+ "case": "constant--2.0",
+ "paddle_raw_max_abs": 4.470348358154297e-08,
+ "paddle_sim_max_abs": 2.9802322387695312e-08,
+ "raw_sim_max_abs": 4.470348358154297e-08,
+ "label": 3,
+ "scores": [
+ [
+ 0.25008657574653625,
+ 0.24269358813762665,
+ 0.24955761432647705,
+ 0.25766217708587646
+ ]
+ ]
+ },
+ {
+ "case": "constant-0.0",
+ "paddle_raw_max_abs": 4.470348358154297e-08,
+ "paddle_sim_max_abs": 8.940696716308594e-08,
+ "raw_sim_max_abs": 4.470348358154297e-08,
+ "label": 0,
+ "scores": [
+ [
+ 0.2545959949493408,
+ 0.25147926807403564,
+ 0.247847780585289,
+ 0.24607694149017334
+ ]
+ ]
+ },
+ {
+ "case": "constant-2.0",
+ "paddle_raw_max_abs": 1.1920928955078125e-07,
+ "paddle_sim_max_abs": 1.4901161193847656e-07,
+ "raw_sim_max_abs": 5.960464477539063e-08,
+ "label": 2,
+ "scores": [
+ [
+ 0.2493261694908142,
+ 0.25364556908607483,
+ 0.26219579577445984,
+ 0.23483245074748993
+ ]
+ ]
+ },
+ {
+ "case": "sample-0",
+ "paddle_raw_max_abs": 1.30385160446167e-08,
+ "paddle_sim_max_abs": 0.0,
+ "raw_sim_max_abs": 1.30385160446167e-08,
+ "label": 0,
+ "scores": [
+ [
+ 0.9245263934135437,
+ 0.025424646213650703,
+ 0.02497117593884468,
+ 0.025077804923057556
+ ]
+ ]
+ },
+ {
+ "case": "sample-90",
+ "paddle_raw_max_abs": 1.30385160446167e-08,
+ "paddle_sim_max_abs": 2.0489096641540527e-08,
+ "raw_sim_max_abs": 1.1175870895385742e-08,
+ "label": 1,
+ "scores": [
+ [
+ 0.025842642411589622,
+ 0.92326819896698,
+ 0.024630922824144363,
+ 0.02625834196805954
+ ]
+ ]
+ },
+ {
+ "case": "sample-180",
+ "paddle_raw_max_abs": 7.450580596923828e-09,
+ "paddle_sim_max_abs": 7.450580596923828e-09,
+ "raw_sim_max_abs": 0.0,
+ "label": 2,
+ "scores": [
+ [
+ 0.024874117225408554,
+ 0.025103045627474785,
+ 0.9255125522613525,
+ 0.024510331451892853
+ ]
+ ]
+ },
+ {
+ "case": "sample-270",
+ "paddle_raw_max_abs": 2.60770320892334e-08,
+ "paddle_sim_max_abs": 2.9802322387695312e-08,
+ "raw_sim_max_abs": 5.587935447692871e-09,
+ "label": 3,
+ "scores": [
+ [
+ 0.025680333375930786,
+ 0.02513287402689457,
+ 0.025028767064213753,
+ 0.924157977104187
+ ]
+ ]
+ }
+ ]
+ },
+ "PP-LCNet_x1_0_textline_ori": {
+ "repository": "https://huggingface.co/PaddlePaddle/PP-LCNet_x1_0_textline_ori",
+ "revision": "cd237a44b0e359d4fe38310a416203cf7403faa5",
+ "source_files": {
+ "inference.json": {
+ "sha256": "b81929eeaff8e52db0fafb49c9fbfc5bc0572f81641fe0179cc52096323fb4d4",
+ "bytes": 104399
+ },
+ "inference.pdiparams": {
+ "sha256": "0de2bcf996cf553e2b848dd7b1769dafffc6917b1ccdf55c1d8efe7909fbf743",
+ "bytes": 6743918
+ },
+ "inference.yml": {
+ "sha256": "8d5120d0e1a30a9df7ed46aa9119da3796ed066777089d1c1d705f132d5e90f9",
+ "bytes": 735
+ },
+ "config.json": {
+ "sha256": "c1a2576f9713adabdc4a58d9d654fcc9ea93b6efd0e2d3581d5aab409a33f14f",
+ "bytes": 2487
+ },
+ "README.md": {
+ "sha256": "c2de067422621e296f697b849b90b8f6cae090a8e0a27d8b04eeb8d68ffb8ce0",
+ "bytes": 3554
+ }
+ },
+ "output": "textline_ori_x1.onnx",
+ "sha256": "38229afe31279f5c17c3c502c6971246f85732b758da108c30afb7ce11db5cb1",
+ "raw_nodes": 310,
+ "simplified_nodes": 113,
+ "operators": {
+ "Constant": 12,
+ "Conv": 32,
+ "HardSigmoid": 30,
+ "Mul": 31,
+ "GlobalAveragePool": 3,
+ "Relu": 2,
+ "Reshape": 1,
+ "Gemm": 1,
+ "Softmax": 1
+ },
+ "comparisons": [
+ {
+ "case": "random-0",
+ "paddle_raw_max_abs": 5.960464477539063e-08,
+ "paddle_sim_max_abs": 2.980232238769531e-07,
+ "raw_sim_max_abs": 3.5762786865234375e-07,
+ "label": 0,
+ "scores": [
+ [
+ 0.7403345704078674,
+ 0.25966542959213257
+ ]
+ ]
+ },
+ {
+ "case": "random-1",
+ "paddle_raw_max_abs": 1.7881393432617188e-07,
+ "paddle_sim_max_abs": 2.980232238769531e-07,
+ "raw_sim_max_abs": 4.76837158203125e-07,
+ "label": 1,
+ "scores": [
+ [
+ 0.4704328179359436,
+ 0.5295671820640564
+ ]
+ ]
+ },
+ {
+ "case": "random-2",
+ "paddle_raw_max_abs": 4.76837158203125e-07,
+ "paddle_sim_max_abs": 1.7881393432617188e-07,
+ "raw_sim_max_abs": 2.980232238769531e-07,
+ "label": 1,
+ "scores": [
+ [
+ 0.4611087441444397,
+ 0.5388912558555603
+ ]
+ ]
+ },
+ {
+ "case": "random-3",
+ "paddle_raw_max_abs": 1.1920928955078125e-07,
+ "paddle_sim_max_abs": 3.5762786865234375e-07,
+ "raw_sim_max_abs": 3.8743019104003906e-07,
+ "label": 1,
+ "scores": [
+ [
+ 0.3718082308769226,
+ 0.6281917095184326
+ ]
+ ]
+ },
+ {
+ "case": "random-4",
+ "paddle_raw_max_abs": 5.960464477539063e-08,
+ "paddle_sim_max_abs": 2.384185791015625e-07,
+ "raw_sim_max_abs": 2.384185791015625e-07,
+ "label": 1,
+ "scores": [
+ [
+ 0.3549545407295227,
+ 0.6450453996658325
+ ]
+ ]
+ },
+ {
+ "case": "random-5",
+ "paddle_raw_max_abs": 2.980232238769531e-07,
+ "paddle_sim_max_abs": 1.4901161193847656e-07,
+ "raw_sim_max_abs": 1.7881393432617188e-07,
+ "label": 0,
+ "scores": [
+ [
+ 0.5350959300994873,
+ 0.4649040997028351
+ ]
+ ]
+ },
+ {
+ "case": "random-6",
+ "paddle_raw_max_abs": 8.940696716308594e-08,
+ "paddle_sim_max_abs": 2.9802322387695312e-08,
+ "raw_sim_max_abs": 5.960464477539063e-08,
+ "label": 1,
+ "scores": [
+ [
+ 0.4676806628704071,
+ 0.5323193073272705
+ ]
+ ]
+ },
+ {
+ "case": "random-7",
+ "paddle_raw_max_abs": 0.0,
+ "paddle_sim_max_abs": 1.7881393432617188e-07,
+ "raw_sim_max_abs": 1.7881393432617188e-07,
+ "label": 0,
+ "scores": [
+ [
+ 0.7325873970985413,
+ 0.26741260290145874
+ ]
+ ]
+ },
+ {
+ "case": "constant--2.0",
+ "paddle_raw_max_abs": 0.0,
+ "paddle_sim_max_abs": 5.960464477539063e-08,
+ "raw_sim_max_abs": 5.960464477539063e-08,
+ "label": 0,
+ "scores": [
+ [
+ 0.5008572936058044,
+ 0.49914276599884033
+ ]
+ ]
+ },
+ {
+ "case": "constant-0.0",
+ "paddle_raw_max_abs": 1.1920928955078125e-07,
+ "paddle_sim_max_abs": 2.980232238769531e-07,
+ "raw_sim_max_abs": 4.172325134277344e-07,
+ "label": 1,
+ "scores": [
+ [
+ 0.4269205629825592,
+ 0.5730794668197632
+ ]
+ ]
+ },
+ {
+ "case": "constant-2.0",
+ "paddle_raw_max_abs": 1.1920928955078125e-07,
+ "paddle_sim_max_abs": 1.1920928955078125e-07,
+ "raw_sim_max_abs": 5.960464477539063e-08,
+ "label": 1,
+ "scores": [
+ [
+ 0.499697208404541,
+ 0.500302791595459
+ ]
+ ]
+ },
+ {
+ "case": "sample-0",
+ "paddle_raw_max_abs": 1.862645149230957e-07,
+ "paddle_sim_max_abs": 1.3783574104309082e-07,
+ "raw_sim_max_abs": 3.241002559661865e-07,
+ "label": 0,
+ "scores": [
+ [
+ 0.9446458220481873,
+ 0.05535417050123215
+ ]
+ ]
+ },
+ {
+ "case": "sample-90",
+ "paddle_raw_max_abs": 2.2817403078079224e-08,
+ "paddle_sim_max_abs": 7.171183824539185e-08,
+ "raw_sim_max_abs": 4.889443516731262e-08,
+ "label": 1,
+ "scores": [
+ [
+ 0.004689699970185757,
+ 0.995310366153717
+ ]
+ ]
+ },
+ {
+ "case": "sample-180",
+ "paddle_raw_max_abs": 7.450580596923828e-07,
+ "paddle_sim_max_abs": 5.960464477539063e-08,
+ "raw_sim_max_abs": 7.748603820800781e-07,
+ "label": 1,
+ "scores": [
+ [
+ 0.4624049663543701,
+ 0.5375950336456299
+ ]
+ ]
+ },
+ {
+ "case": "sample-270",
+ "paddle_raw_max_abs": 5.960464477539063e-08,
+ "paddle_sim_max_abs": 4.172325134277344e-07,
+ "raw_sim_max_abs": 3.5762786865234375e-07,
+ "label": 0,
+ "scores": [
+ [
+ 0.715459942817688,
+ 0.2845400869846344
+ ]
+ ]
+ }
+ ]
+ }
+ }
+}
\ No newline at end of file
diff --git a/models/textline_ori_x1.onnx b/models/textline_ori_x1.onnx
new file mode 100644
index 0000000..d9392d5
Binary files /dev/null and b/models/textline_ori_x1.onnx differ
diff --git a/src/Sdcb.SimdPaddleOCR.ModelProvider/PaddleOcrModelKind.cs b/src/Sdcb.SimdPaddleOCR.ModelProvider/PaddleOcrModelKind.cs
index e9dc879..dfe7a67 100644
--- a/src/Sdcb.SimdPaddleOCR.ModelProvider/PaddleOcrModelKind.cs
+++ b/src/Sdcb.SimdPaddleOCR.ModelProvider/PaddleOcrModelKind.cs
@@ -6,4 +6,6 @@ public enum PaddleOcrModelKind
Classification,
Recognition,
Dictionary,
+ /// 整页纸张的 0°、90°、180°、270° 方向分类模型,作为完整 OCR 的第一阶段。
+ DocumentOrientation,
}
diff --git a/src/Sdcb.SimdPaddleOCR/Backends/Vulkan/GpuGraph.cs b/src/Sdcb.SimdPaddleOCR/Backends/Vulkan/GpuGraph.cs
index bb3f296..ed942d6 100644
--- a/src/Sdcb.SimdPaddleOCR/Backends/Vulkan/GpuGraph.cs
+++ b/src/Sdcb.SimdPaddleOCR/Backends/Vulkan/GpuGraph.cs
@@ -1272,7 +1272,12 @@ bool Absorbable(int cn, int srcPhys)
uint mImg = (uint)(outH * outW);
uint M = mImg * (uint)nb; // flat rows span the batch
bool dotOk = cout % 4 == 0 && scalarBias == 0
- && (cinIn <= 128 || (hasPs && cinIn <= 256))
+ && (cinIn <= 128 || (hasPs && cinIn <= 256)
+ // LCNet 的 14×14 特征图及 1×1 分类头在协作矩阵路径上利用率较低。
+ // sg32 设备上的小矩阵改用 Split-K 点积,增加可并行的线程束数量。
+ // 已在 RTX 3060 的 256/512 输入通道上实测;环境变量置 0 可回到原路径对比。
+ || (_sg32 && M <= 256 && cinIn <= 512
+ && Environment.GetEnvironmentVariable("SIMD_OCR_NARROW_DOT") != "0"))
&& Environment.GetEnvironmentVariable("SIMD_OCR_NODOT") == null;
// sg32 coopmat (SE prescale included) outruns the dot
// kernel once the output leaves split-K range (lite:
diff --git a/src/Sdcb.SimdPaddleOCR/DocumentRotation.cs b/src/Sdcb.SimdPaddleOCR/DocumentRotation.cs
new file mode 100644
index 0000000..f1d27ae
--- /dev/null
+++ b/src/Sdcb.SimdPaddleOCR/DocumentRotation.cs
@@ -0,0 +1,83 @@
+namespace Sdcb.SimdPaddleOCR;
+
+/// 整页逆时针直角旋转,以及将校正图中的检测框还原到原图的坐标工具。
+internal static class DocumentRotation
+{
+ /// 计算旋转后的图像尺寸;旋转 90° 或 270° 时交换宽高。
+ /// 旋转前的宽度,单位为像素。
+ /// 旋转前的高度,单位为像素。
+ /// 逆时针旋转角度,调用方应传入 0、90、180 或 270。
+ /// 旋转后的宽度和高度。
+ internal static (int Width, int Height) Size(int width, int height, int degrees) =>
+ degrees is 90 or 270 ? (height, width) : (width, height);
+
+ /// 按整数像素坐标逆时针旋转整页,保留各像素的原始通道值,不执行插值。
+ /// 旋转前的像素缓冲区;不得与目标缓冲区重叠。
+ /// 原图宽度,单位为像素,必须为正数。
+ /// 原图高度,单位为像素,必须为正数。
+ /// 原图每行的字节跨度,允许行尾填充,至少为一行像素所需字节数。
+ /// 原图和目标图共同使用的像素格式。
+ /// 逆时针旋转角度,只允许 0、90、180、270。
+ /// 目标像素缓冲区,按行紧密排列,容量至少为 width×height×每像素字节数。
+ internal static void Rotate(ReadOnlySpan source, int width, int height, int stride,
+ ImagePixelFormat format, int degrees, Span destination)
+ {
+ int bpp = ImagePixels.BytesPerPixel(format);
+ if (width < 1 || height < 1 || stride < checked(width * bpp) ||
+ (long)(height - 1) * stride + (long)width * bpp > source.Length)
+ throw new ArgumentException("Invalid source image.");
+ if (degrees is not (0 or 90 or 180 or 270)) throw new ArgumentOutOfRangeException(nameof(degrees));
+ if (destination.Length < checked(width * height * bpp)) throw new ArgumentException("Destination is too small.");
+ (int rotatedWidth, _) = Size(width, height, degrees);
+ for (int y = 0; y < height; y++)
+ for (int x = 0; x < width; x++)
+ {
+ // 将原图像素映射到目标位置;使用 width/height - 1 对齐像素中心的零起始坐标。
+ (int dx, int dy) = degrees switch
+ {
+ 90 => (y, width - 1 - x),
+ 180 => (width - 1 - x, height - 1 - y),
+ 270 => (height - 1 - y, x),
+ _ => (x, y)
+ };
+ source.Slice(y * stride + x * bpp, bpp).CopyTo(destination.Slice((dy * rotatedWidth + dx) * bpp, bpp));
+ }
+ }
+
+ /// 把旋转校正图中的四边形逐点逆变换到原图,保留顶点顺序和检测分数。
+ /// 在校正图坐标系中的检测框。
+ /// 旋转前的原图宽度,单位为像素。
+ /// 旋转前的原图高度,单位为像素。
+ /// 生成校正图时执行的逆时针旋转角度。
+ /// 原图坐标系中的检测框;保留校正图中的顶点顺序,不重新按原图左上角排序。
+ internal static PaddleOcrDetectionBox ToSource(PaddleOcrDetectionBox box, int width, int height, int degrees)
+ {
+ // Point 的 x/y 来自校正图;这里采用 Rotate 中映射公式的逆变换。
+ (float X, float Y) Point(float x, float y) => degrees switch
+ {
+ 90 => (width - 1 - y, x),
+ 180 => (width - 1 - x, height - 1 - y),
+ 270 => (y, height - 1 - x),
+ _ => (x, y)
+ };
+ var p1 = Point(box.X1, box.Y1); var p2 = Point(box.X2, box.Y2);
+ var p3 = Point(box.X3, box.Y3); var p4 = Point(box.X4, box.Y4);
+ return new(p1.X, p1.Y, p2.X, p2.Y, p3.X, p3.Y, p4.X, p4.Y, box.Score);
+ }
+
+ /// 复制识别行并还原检测框坐标,保留文本、行方向和 CTC 对齐信息。
+ /// 在校正图中生成的识别行。
+ /// 旋转前的原图宽度,单位为像素。
+ /// 旋转前的原图高度,单位为像素。
+ /// 生成校正图时执行的逆时针旋转角度。
+ /// 坐标已还原的识别行;字符框可继续根据保留阅读顺序的顶点和 CTC 信息估算。
+ internal static PaddleOcrLine ToSource(PaddleOcrLine line, int width, int height, int degrees) => new()
+ {
+ Box = ToSource(line.Box, width, height, degrees), Text = line.Text,
+ RecognitionScore = line.RecognitionScore, ClassificationScore = line.ClassificationScore,
+ ClassificationLabel = line.ClassificationLabel, AppliedRotationDegrees = line.AppliedRotationDegrees,
+ EmittedCount = line.EmittedCount, CtcSpans = line.CtcSpans,
+ RecognitionContentWidth = line.RecognitionContentWidth, RecognitionTensorWidth = line.RecognitionTensorWidth,
+ RecognitionTimeSteps = line.RecognitionTimeSteps
+ };
+}
diff --git a/src/Sdcb.SimdPaddleOCR/OnnxSharp/Model.cs b/src/Sdcb.SimdPaddleOCR/OnnxSharp/Model.cs
index 6d04bca..0664af2 100644
--- a/src/Sdcb.SimdPaddleOCR/OnnxSharp/Model.cs
+++ b/src/Sdcb.SimdPaddleOCR/OnnxSharp/Model.cs
@@ -418,7 +418,9 @@ private static void BuildOnnxRecords(OnnxGraphData graph, int opset,
OnnxAttributeData? value = constant.Attributes.FirstOrDefault(a => a.Name == "value");
if (value?.Tensor is OnnxTensorData tensor)
{
- if (tensor.Name.Length == 0) tensor.Name = constant.Outputs[0];
+ // TensorProto.name 只是描述性名称;图连接实际使用 Constant 的输出边名称。
+ // onnxsim 可能只重命名输出边,必须以输出边为准登记常量,避免常量丢失。
+ tensor.Name = constant.Outputs[0];
initializers[constant.Outputs[0]] = tensor;
continue;
}
@@ -556,6 +558,12 @@ static OnnxAttributeData MakeFloats(string name, params float[] values)
string[] resolvedOutputs = [.. node.Outputs.Select(Resolve)];
node.Inputs.Clear(); node.Inputs.AddRange(resolvedInputs);
node.Outputs.Clear(); node.Outputs.AddRange(resolvedOutputs);
+ if (node.OpType == "Gemm" && node.Domain is "" or "ai.onnx")
+ {
+ // 仅转换标准 ONNX Gemm,自定义域中同名算子不能套用此规则。
+ LowerGemm(node, transformed, initializers, graph);
+ continue;
+ }
transformed.Add(node);
}
@@ -670,6 +678,96 @@ private static void NormalizeSameUpper(OnnxNodeData node)
node.Attributes.Remove(auto); node.Attributes.Add(new OnnxAttributeData { Name = "pads", Ints = { ph / 2, pw / 2, ph - ph / 2, pw - pw / 2 } });
}
+ /// 将 Gemm 转换为现有后端支持的 MatMul、Add、Mul 和必要的转置节点。
+ /// 待展开的标准 ONNX Gemm,计算 alpha×op(A)×op(B)+beta×C,C 可省略。
+ /// 按拓扑顺序接收展开节点的列表。
+ /// 常量表;转换时加入预转置权重、缩放权重或标量常量。
+ /// 原始图,用于避开已有名称并补充中间张量的类型和形状信息。
+ ///
+ /// onnxsim 会把模型末端的 MatMul+Add 合并为 Gemm;导入时再展开即可复用 CPU、WASM
+ /// 的打包矩阵权重和 GPU 矩阵计算路径,无需在各后端重复增加 Gemm 实现。
+ ///
+ private static void LowerGemm(OnnxNodeData node, List nodes,
+ Dictionary initializers, OnnxGraphData graph)
+ {
+ if (node.Inputs.Count is < 2 or > 3 || node.Outputs.Count != 1)
+ throw new InvalidDataException($"Invalid Gemm '{node.Name}'.");
+ long transA = node.Attributes.FirstOrDefault(a => a.Name == "transA")?.Int ?? 0;
+ long transB = node.Attributes.FirstOrDefault(a => a.Name == "transB")?.Int ?? 0;
+ if (transA is not (0 or 1) || transB is not (0 or 1))
+ throw new InvalidDataException("Gemm transpose attributes must be 0 or 1.");
+ float alpha = node.Attributes.FirstOrDefault(a => a.Name == "alpha")?.Float ?? 1f;
+ float beta = node.Attributes.FirstOrDefault(a => a.Name == "beta")?.Float ?? 1f;
+ string prefix = node.Outputs[0] + ".sdcb_gemm";
+ HashSet names = new(initializers.Keys, StringComparer.Ordinal);
+ foreach (OnnxNodeData n in graph.Nodes)
+ foreach (string name in n.Inputs.Concat(n.Outputs)) names.Add(name);
+ // 生成新张量名称:suffix 是用途后缀,遇到与原图名称冲突时追加下划线。
+ string Unique(string suffix)
+ {
+ string name = prefix + suffix;
+ while (!names.Add(name)) name += "_";
+ return name;
+ }
+ // 追加节点:op 为算子类型,inputs 为输入边,output 为输出边;transpose 表示交换两个矩阵轴。
+ void Emit(string op, string[] inputs, string output, bool transpose = false)
+ {
+ OnnxNodeData n = new() { Name = output, OpType = op };
+ n.Inputs.AddRange(inputs); n.Outputs.Add(output);
+ if (transpose) n.Attributes.Add(new OnnxAttributeData { Name = "perm", Ints = { 1, 0 } });
+ nodes.Add(n);
+ OnnxValueInfoData? info = graph.ValueInfo.Concat(graph.Outputs).FirstOrDefault(v => v.Name == node.Outputs[0]);
+ if (info is not null && !transpose)
+ graph.ValueInfo.Add(new OnnxValueInfoData { Name = output, ElementType = info.ElementType, Shape = info.Shape });
+ }
+ // 生成缩放后的张量边:input 为原张量名称,factor 为系数;常量直接折叠,动态值使用 Mul。
+ string Scale(string input, float factor)
+ {
+ if (factor == 1f) return input;
+ string output = Unique(".scale");
+ if (initializers.TryGetValue(input, out OnnxTensorData? tensor))
+ initializers[output] = MakeFloatTensor(output, tensor.Dims, TensorFloats(tensor).Select(v => v * factor).ToArray());
+ else
+ {
+ string scalar = Unique(".factor");
+ initializers[scalar] = MakeFloatTensor(scalar, [], [factor]);
+ Emit("Mul", [input, scalar], output);
+ }
+ return output;
+ }
+ string a = node.Inputs[0], b = node.Inputs[1];
+ if (transA == 1) { string t = Unique(".aT"); Emit("Transpose", [a], t, true); a = t; }
+ if (transB == 1)
+ {
+ string t = Unique(".bT");
+ if (initializers.TryGetValue(b, out OnnxTensorData? tensor))
+ {
+ if (tensor.Dims.Count != 2) throw new InvalidDataException("Gemm B must have rank 2.");
+ // 常量 B 在导入时完成转置,避免每次推理转置,并保留后续 MatMul 权重打包能力。
+ int rows = checked((int)tensor.Dims[0]), cols = checked((int)tensor.Dims[1]);
+ float[] input = TensorFloats(tensor), output = new float[input.Length];
+ for (int r = 0; r < rows; r++) for (int c = 0; c < cols; c++) output[c * rows + r] = input[r * cols + c];
+ initializers[t] = MakeFloatTensor(t, [cols, rows], output);
+ }
+ else Emit("Transpose", [b], t, true);
+ b = t;
+ }
+ // alpha 折叠进常量 B,省去一次完整输出遍历;动态 B 则在 MatMul 后缩放以保留原表达式。
+ bool scaleAfter = alpha != 1f && !initializers.ContainsKey(b);
+ if (!scaleAfter) b = Scale(b, alpha);
+ bool bias = node.Inputs.Count == 3 && node.Inputs[2].Length != 0;
+ string product = bias || scaleAfter ? Unique(".product") : node.Outputs[0];
+ Emit("MatMul", [a, b], product);
+ if (scaleAfter)
+ {
+ string factor = Unique(".alpha"); initializers[factor] = MakeFloatTensor(factor, [], [alpha]);
+ string scaled = bias ? Unique(".scaled") : node.Outputs[0];
+ Emit("Mul", [product, factor], scaled); product = scaled;
+ }
+ // C 的标量、行或列偏置继续使用现有 Add 广播规则;beta 也可直接折叠进常量 C。
+ if (bias) Emit("Add", [product, Scale(node.Inputs[2], beta)], node.Outputs[0]);
+ }
+
private static void FoldConvBatchNormalization(List nodes,
Dictionary initializers, OnnxGraphData graph)
{
diff --git a/src/Sdcb.SimdPaddleOCR/PPOCRPreprocess.cs b/src/Sdcb.SimdPaddleOCR/PPOCRPreprocess.cs
index 0f2dcb6..7587aff 100644
--- a/src/Sdcb.SimdPaddleOCR/PPOCRPreprocess.cs
+++ b/src/Sdcb.SimdPaddleOCR/PPOCRPreprocess.cs
@@ -15,6 +15,77 @@ internal static class PPOCRPreprocess
// divide/multiply from the hot loops while retaining the same rounding.
private static readonly float[] DetNormalized = BuildChannelLut(DetMean, DetInverseStd);
private static readonly float[] RecNormalized = BuildRecLut();
+ // 三个 RGB 通道各保存 256 个归一化值,热循环只查表,避免逐像素浮点除法。
+ private static readonly float[] DocumentNormalized = BuildDocumentLut();
+
+ /// 建立纸张方向模型的 RGB 归一化表,与 PaddleX 的 float32 运算顺序保持一致。
+ /// 长度为 768 的查找表,索引为通道编号×256+原始八位像素值。
+ private static float[] BuildDocumentLut()
+ {
+ float[] lut = new float[768];
+ double[] std = [0.229, 0.224, 0.225];
+ for (int c = 0; c < 3; c++)
+ for (int v = 0; v < 256; v++)
+ // 先把 alpha、beta 转为 float,再执行 v*alpha+beta,避免改变参考实现的舍入结果。
+ lut[c * 256 + v] = v * (float)(1.0 / 255 / std[c]) + (float)(-DetMean[c] / std[c]);
+ return lut;
+ }
+
+ /// 执行纸张方向模型的预处理:短边缩放至 256、中心裁剪 224×224、RGB 归一化。
+ /// 原始交错通道像素,允许行尾填充。
+ /// 原图宽度,单位为像素,必须为正数。
+ /// 原图高度,单位为像素,必须为正数。
+ /// 已解析的行字节跨度,至少为 width×每像素字节数,不能传入 0。
+ /// 接收模型输入的浮点缓冲区,长度必须为 3×224×224。
+ /// 本次请求独占的缩放工作区,用于复用横向系数和两行中间结果。
+ /// true 输出 NHWC 交错布局;false 输出 NCHW 分通道布局。
+ /// 原图像素格式;四通道格式会忽略 alpha 通道,默认 BGR24。
+ /// 仅计算缩放图中需要的中心裁剪区域,极宽图像也无需分配完整缩放图。
+ internal static unsafe void DocumentOrientation(ReadOnlySpan source, int width, int height,
+ int stride, Span output, ResizeWorkspace workspace, bool nhwc,
+ ImagePixelFormat format = ImagePixelFormat.Bgr24)
+ {
+ ValidateSource(source, width, height, stride, format);
+ const int size = 224, plane = size * size;
+ if (output.Length != plane * 3) throw new ArgumentException("Invalid document orientation input size.");
+ // 与 Python round 一致,使用中点取偶规则计算缩放后的宽高,再定位中心裁剪区域。
+ double scale = 256.0 / Math.Min(width, height);
+ int resizedWidth = checked((int)Math.Round(width * scale, MidpointRounding.ToEven));
+ int resizedHeight = checked((int)Math.Round(height * scale, MidpointRounding.ToEven));
+ int cropX = (resizedWidth - size) / 2, cropY = (resizedHeight - size) / 2;
+ workspace.Ensure(size);
+ BuildLinearCoefficients(width, resizedWidth, workspace.XOffsets, workspace.XCoefficients, size, cropX);
+ // 横向缩放函数默认输出 BGR;交换它对输入格式的解释,使输出改为模型要求的 RGB。
+ ImagePixelFormat rgbFormat = format switch
+ {
+ ImagePixelFormat.Bgr24 => ImagePixelFormat.Rgb24,
+ ImagePixelFormat.Rgb24 => ImagePixelFormat.Bgr24,
+ ImagePixelFormat.Bgra32 => ImagePixelFormat.Rgba32,
+ ImagePixelFormat.Rgba32 => ImagePixelFormat.Bgra32,
+ _ => throw new ArgumentOutOfRangeException(nameof(format))
+ };
+ fixed (byte* src = source)
+ fixed (float* dst = output)
+ {
+ for (int y = 0; y < size; y++)
+ {
+ GetLinearCoordinate(y + cropY, height, resizedHeight, out int sy, out short b0, out short b1);
+ BuildHorizontalRow(src, stride, width, MathCompat.Clamp(sy, 0, height - 1), size,
+ workspace.XOffsets, workspace.XCoefficients, workspace.Row0, rgbFormat);
+ BuildHorizontalRow(src, stride, width, MathCompat.Clamp(sy + 1, 0, height - 1), size,
+ workspace.XOffsets, workspace.XCoefficients, workspace.Row1, rgbFormat);
+ for (int x = 0; x < size; x++)
+ for (int c = 0; c < 3; c++)
+ {
+ int k = x * 3 + c;
+ // 保留与 OpenCV 八位双线性缩放一致的定点位移及舍入,再查询归一化值。
+ int value = MathCompat.Clamp((((workspace.Row0[k] >> 4) * b0 >> 16) +
+ ((workspace.Row1[k] >> 4) * b1 >> 16) + 2) >> 2, 0, 255);
+ dst[nhwc ? (y * size + x) * 3 + c : c * plane + y * size + x] = DocumentNormalized[c * 256 + value];
+ }
+ }
+ }
+ }
[ThreadStatic] private static ResizeWorkspace? t_workspace;
@@ -225,18 +296,23 @@ private static unsafe void DetRow(byte* sourcePtr, int sourceStride, int sourceW
}
}
}
+ /// 为缩放目标轴的一段连续坐标预计算双线性采样位置和定点权重。
+ /// 原图在该轴上的像素数。
+ /// 完整缩放图在该轴上的像素数,用于确定缩放比例。
+ /// 接收每个目标坐标对应的原图起始采样位置,容量至少为 count。
+ /// 接收左右采样权重,每个目标坐标占两个元素,权重单位为 1/2048。
+ /// 需要计算的目标坐标数;负数表示计算完整目标轴。
+ /// 首个目标坐标在完整缩放图中的偏移;中心裁剪时为裁剪起点。
private static void BuildLinearCoefficients(int sourceSize, int destinationSize,
- int[] offsets, short[] coefficients)
+ int[] offsets, short[] coefficients, int count = -1, int start = 0)
{
const int scale = 1 << 11;
- for (int d = 0; d < destinationSize; d++)
+ if (count < 0) count = destinationSize;
+ for (int d = 0; d < count; d++)
{
- // OpenCV computes the interpolation coordinate in softdouble
- // (effectively double precision). Keeping the coordinate in
- // float can move coefficients by one at exact/tie boundaries for
- // narrow text crops (for example 28 -> 160), changing the final
- // 8-bit pixel after the vertical fixed-point pass.
- double coordinate = ((double)d + 0.5) * sourceSize / destinationSize - 0.5;
+ // 使用双精度计算半像素中心坐标,与 OpenCV 的 softdouble 保持一致。
+ // 单精度会在边界处改变定点权重,进而影响纵向缩放后的最终八位像素值。
+ double coordinate = ((double)d + start + 0.5) * sourceSize / destinationSize - 0.5;
int source = checked((int)Math.Floor(coordinate));
double fraction = coordinate - source;
if (source < 0) { source = 0; fraction = 0; }
diff --git a/src/Sdcb.SimdPaddleOCR/PPOCRTypes.cs b/src/Sdcb.SimdPaddleOCR/PPOCRTypes.cs
index 3f2b069..81612df 100644
--- a/src/Sdcb.SimdPaddleOCR/PPOCRTypes.cs
+++ b/src/Sdcb.SimdPaddleOCR/PPOCRTypes.cs
@@ -14,8 +14,8 @@ public sealed class PaddleOcrDetectorOptions
/// Upper bound on pooled reusable sessions. Sessions returned while the pool
/// is full are disposed instead of pooled, capping memory at the cost of
/// re-creating sessions when concurrency exceeds the cap. The default is
- /// , enough for one session per core;
- /// set to for an unbounded pool.
+ /// , enough for one session per core;
+ /// set to for an unbounded pool.
///
public int MaxPooledSessions { get; init; } = Environment.ProcessorCount;
public int LimitSideLength { get; init; } = 960;
@@ -45,12 +45,34 @@ public sealed class PaddleOcrClassifierOptions
///
/// Upper bound on pooled reusable sessions. Sessions returned while the pool
/// is full are disposed instead of pooled. The default is
- /// ; set to
+ /// ; set to
/// for an unbounded pool.
///
public int MaxPooledSessions { get; init; } = Environment.ProcessorCount;
}
+/// 四方向纸张识别阶段的计算后端、线程预算和资源上限。
+public sealed class PaddleOcrDocumentOrientationOptions
+{
+ /// 推理后端;默认 Auto,由运行平台和可用设备选择 CPU 或 GPU。
+ public OcrBackend Backend { get; init; } = OcrBackend.Auto;
+ /// CPU 算子线程预算;0 表示最多使用 4 个逻辑处理器,显式设置允许 1~16。
+ public int IntraOpThreads { get; init; }
+ /// 输入图像允许的最大像素数,默认四千万;用于在预处理前限制资源消耗。
+ public long MaxImagePixels { get; init; } = 40_000_000;
+ /// 保留的空闲推理会话上限,默认逻辑处理器数;0 表示每次使用后立即释放。
+ public int MaxPooledSessions { get; init; } = Environment.ProcessorCount;
+}
+
+/// 纸张在原图中的顺时针方向与对应置信度;校正时应按相同角度逆时针旋转。
+/// 最高分的类别索引;0、1、2、3 分别对应 0°、90°、180°、270°。
+/// 该类别的模型输出分数;官方模型输出为 softmax 概率。
+public readonly record struct PaddleOcrDocumentOrientationResult(uint Label, float Score)
+{
+ /// 原图的顺时针纸张方向,取值为 0、90、180、270 度。
+ public int OrientationDegrees => checked((int)Label * 90);
+}
+
/// Options for the CTC recognizer.
public sealed class PaddleOcrRecognizerOptions
{
@@ -58,8 +80,8 @@ public sealed class PaddleOcrRecognizerOptions
/// Upper bound on pooled reusable sessions. Sessions returned while the pool
/// is full are disposed instead of pooled, capping memory at the cost of
/// re-creating sessions when concurrency exceeds the cap. The default is
- /// , enough for one session per core;
- /// set to for an unbounded pool.
+ /// , enough for one session per core;
+ /// set to for an unbounded pool.
///
public int MaxPooledSessions { get; init; } = Environment.ProcessorCount;
public int TargetWidth { get; init; } = 320;
@@ -84,6 +106,13 @@ public sealed class PaddleOcrRecognizerOptions
///
public sealed class PaddleOcrOptions
{
+ /// 是否执行纸张方向识别;默认开启,但只有显式传入独立纸张方向模型时才运行第一阶段。
+ public bool UseDocumentOrientationClassification { get; init; } = true;
+ /// 整页旋转的最低置信度,范围 0~1;默认 0,按模型最高分类执行校正。低于阈值时仍返回方向结果。
+ public float DocumentOrientationThreshold { get; init; }
+ /// 纸张方向识别阶段的独立后端、线程和资源配置。
+ public PaddleOcrDocumentOrientationOptions DocumentOrientation { get; init; } = new();
+ /// 是否对裁剪后的文本行执行 0°/180° 分类;与整页纸张方向识别分别控制。
public bool UseDirectionClassification { get; init; } = true;
///
/// Minimum class-1 probability required for a 180-degree rotation.
@@ -167,10 +196,12 @@ public readonly record struct PaddleOcrClassificationResult(uint Label, float Sc
public int OrientationDegrees => Label == 0 ? 0 : 180;
}
-/// CTC decode of one crop. is null unless alignment was requested.
-///
-/// Recognizer content width after the height-48 resize, before right padding.
-///
+/// 单个文本裁剪图的 CTC 识别结果;未请求对齐时 CtcSpans 为 null。
+/// CTC 解码后的文本。
+/// 该段文本的识别分数。
+/// 解码输出的词项数量。
+/// 缩放至高 48 后、右侧补零之前的有效内容宽度。
+/// 识别模型输出的 CTC 时间步数。
public readonly record struct PaddleOcrRecognitionResult(string Text, float Score, int EmittedCount,
int ResizedWidth, int TimeSteps)
{
@@ -230,19 +261,28 @@ public sealed class PaddleOcrLine
public int RecognitionTimeSteps { get; init; }
///
- /// Maps onto . Blank gaps between
- /// spans are split at their midpoint. Requires returnCtcAlignment: true
- /// on the or
- /// call that produced this line.
+ /// 根据 和 估算原图坐标中的字符四边形,空白间隔从中点分割。
+ /// 生成本行时须在
+ /// 或
+ /// 中传入 returnCtcAlignment: true。
///
+ /// 已考虑整页及文本行旋转的字符框;没有输出字符时返回空数组。
+ /// 生成本行时未保留 CTC 对齐信息。
public PaddleOcrCharacterBox[] EstimateCharacterBoxes() => PaddleOcrCharacterBoxes.Estimate(this);
}
public sealed class PaddleOcrResult
{
+ /// 整页方向预测与置信度;未传入纸张模型或关闭该阶段时为 null。
+ public PaddleOcrDocumentOrientationResult? DocumentOrientation { get; init; }
+ /// 文本检测前实际执行的逆时针整页旋转角度;为 0、90、180、270,低置信度跳过旋转时为 0。
+ public int AppliedDocumentRotationDegrees { get; init; }
+ /// 按校正图阅读顺序排列的识别行;行框及可估算的字符框均已还原到原始输入图坐标。
public required PaddleOcrLine[] Lines { get; init; }
public required int DetectedCount { get; init; }
+ /// 检测器实际使用的缩放图宽度;纸张校正开启时基于旋转后的图像计算。
public required int DetectorResizedWidth { get; init; }
+ /// 检测器实际使用的缩放图高度;纸张校正开启时基于旋转后的图像计算。
public required int DetectorResizedHeight { get; init; }
public string Text => string.Join("\n", Lines.Select(x => x.Text));
diff --git a/src/Sdcb.SimdPaddleOCR/PaddleOcrAll.cs b/src/Sdcb.SimdPaddleOCR/PaddleOcrAll.cs
index 5b5a373..6141ca5 100644
--- a/src/Sdcb.SimdPaddleOCR/PaddleOcrAll.cs
+++ b/src/Sdcb.SimdPaddleOCR/PaddleOcrAll.cs
@@ -9,13 +9,10 @@
namespace Sdcb.SimdPaddleOCR;
///
-/// Complete pure managed PP-OCR pipeline. It accepts packed BGR8 memory and
-/// deliberately has no image-decoding or file-system dependency.
+/// 完整 OCR 流水线,接收原始像素,依次执行可选的纸张方向校正、文本检测、文本行分类及文字识别。
///
-/// Thread-safe: the detector, classifier and recognizer each hold one shared
-/// compiled model and pool per-call inference requests, while every run rents
-/// its own crop workspace. Concurrent calls are safe and
-/// execute without a global engine lock.
+/// 各阶段共享只读编译模型,并分别池化推理会话;每次请求独占裁剪和整页旋转缓冲区,支持并发 Run。
+/// 返回的行框和字符框使用原始输入图坐标;图像解码由调用方完成。
///
///
public sealed class PaddleOcrAll : IDisposable
@@ -25,6 +22,8 @@ public sealed class PaddleOcrAll : IDisposable
private readonly PaddleOcrDetector _detector;
private readonly PaddleOcrClassifier? _classifier;
private readonly PaddleOcrRecognizer _recognizer;
+ private readonly PaddleOcrDocumentOrientationClassifier? _documentOrientation; // 未提供纸张模型或禁用阶段时为 null。
+ private Model? _ownedDocumentOrientationModel; // 通过独立 provider 加载的模型由引擎统一释放。
private readonly PaddleOcrOptions _options;
private readonly int _lineWorkers;
private readonly int _cropWorkers;
@@ -65,6 +64,61 @@ public static async Task LoadAsync(PaddleOcrModelBundle bundle,
PaddleOcrOptions? options = null, CancellationToken cancellationToken = default)
=> new(await PaddleOcrModelSet.LoadAsync(bundle, cancellationToken).ConfigureAwait(false), options);
+ /// 从 OCR 模型集合和独立纸张方向模型提供方加载带第一阶段的完整流水线。
+ /// 文本检测、文本行分类、识别和字典组成的 OCR 模型集合。
+ /// 独立四方向模型提供方,Kind 必须为 DocumentOrientation。
+ /// 流水线和各阶段配置;传入 null 使用默认配置。
+ /// 拥有本次加载模型的 OCR 引擎;使用完毕后由调用方释放。
+ public static PaddleOcrAll Load(PaddleOcrModelBundle bundle, IPaddleOcrModelProvider documentOrientation,
+ PaddleOcrOptions? options)
+ {
+ ValidateDocumentProvider(documentOrientation);
+ using Stream stream = documentOrientation.OpenRead();
+ Model orientation = Model.Load(stream);
+ PaddleOcrModelSet? models = null;
+ try
+ {
+ models = PaddleOcrModelSet.Load(bundle);
+ return new PaddleOcrAll(models.DetectionModel, models.ClassificationModel, models.RecognitionModel,
+ models.DictionaryUtf8.Span, options, orientation)
+ { _ownedModels = models, _ownedDocumentOrientationModel = orientation };
+ }
+ catch { models?.Dispose(); orientation.Dispose(); throw; }
+ }
+
+ /// 异步加载 OCR 模型集合及独立纸张方向模型,构造带整页校正的流水线。
+ /// 文本检测、文本行分类、识别和字典组成的 OCR 模型集合。
+ /// 独立四方向模型提供方,Kind 必须为 DocumentOrientation。
+ /// 流水线和各阶段配置;传入 null 使用默认配置。
+ /// 取消模型流读取、解析及模型集合加载的令牌。
+ /// 拥有本次加载模型的 OCR 引擎。
+ public static async Task LoadAsync(PaddleOcrModelBundle bundle,
+ IPaddleOcrModelProvider documentOrientation, PaddleOcrOptions? options,
+ CancellationToken cancellationToken = default)
+ {
+ ValidateDocumentProvider(documentOrientation);
+ using Stream stream = await documentOrientation.OpenReadAsync(cancellationToken).ConfigureAwait(false);
+ Model orientation = await Model.LoadAsync(stream, cancellationToken).ConfigureAwait(false);
+ PaddleOcrModelSet? models = null;
+ try
+ {
+ models = await PaddleOcrModelSet.LoadAsync(bundle, cancellationToken).ConfigureAwait(false);
+ return new PaddleOcrAll(models.DetectionModel, models.ClassificationModel, models.RecognitionModel,
+ models.DictionaryUtf8.Span, options, orientation)
+ { _ownedModels = models, _ownedDocumentOrientationModel = orientation };
+ }
+ catch { models?.Dispose(); orientation.Dispose(); throw; }
+ }
+
+ /// 验证提供方属于四方向纸张模型,避免误将文本行两分类模型接入第一阶段。
+ /// 待验证的非空模型提供方。
+ private static void ValidateDocumentProvider(IPaddleOcrModelProvider provider)
+ {
+ if (provider is null) throw new ArgumentNullException(nameof(provider));
+ if (provider.Kind != PaddleOcrModelKind.DocumentOrientation)
+ throw new ArgumentException("Expected a four-way document orientation provider.", nameof(provider));
+ }
+
/// Loads a complete OCR pipeline directly from model streams.
public static PaddleOcrAll Load(
Stream detectionOnnx,
@@ -105,8 +159,25 @@ public PaddleOcrAll(
{
}
+ /// 使用已加载的 OCR 模型创建不带纸张方向阶段的流水线。
+ /// 文本检测模型,由调用方管理其生命周期。
+ /// 文本行 0°/180° 分类模型;关闭文本行分类时可为 null。
+ /// 文字识别模型,由调用方管理其生命周期。
+ /// UTF-8 字典内容,定义识别类别对应的字符。
+ /// 流水线配置;为 null 时使用默认配置。
public PaddleOcrAll(Model detectorModel, Model? classifierModel, Model recognizerModel,
ReadOnlySpan dictionaryUtf8, PaddleOcrOptions? options = null)
+ : this(detectorModel, classifierModel, recognizerModel, dictionaryUtf8, options, null) { }
+
+ /// 使用已加载模型创建流水线,可显式接入独立的四方向纸张模型。
+ /// 文本检测模型,由调用方管理其生命周期。
+ /// 文本行 0°/180° 分类模型;关闭文本行分类时可为 null。
+ /// 文字识别模型,由调用方管理其生命周期。
+ /// UTF-8 字典内容,定义识别类别对应的字符。
+ /// 流水线配置;为 null 时使用默认配置。
+ /// 四方向纸张模型;为 null 时跳过第一阶段,模型本身由调用方释放。
+ public PaddleOcrAll(Model detectorModel, Model? classifierModel, Model recognizerModel,
+ ReadOnlySpan dictionaryUtf8, PaddleOcrOptions? options, Model? documentOrientationModel)
{
_options = options ?? new PaddleOcrOptions();
if (_options.LineWorkerCount is < 0 or > Parallelism.MaxLineWorkers)
@@ -118,6 +189,8 @@ public PaddleOcrAll(Model detectorModel, Model? classifierModel, Model recognize
if (_options.RecBatchLines < 1) throw new ArgumentOutOfRangeException(nameof(options));
if (_options.ClassifierThreshold is < 0 or > 1 || !MathCompat.IsFinite(_options.ClassifierThreshold))
throw new ArgumentOutOfRangeException(nameof(options));
+ if (_options.DocumentOrientationThreshold is < 0 or > 1 || !MathCompat.IsFinite(_options.DocumentOrientationThreshold))
+ throw new ArgumentOutOfRangeException(nameof(options));
if (!_options.UseDirectionClassification) classifierModel = null;
if (_options.UseDirectionClassification && classifierModel is null)
throw new ArgumentNullException(nameof(classifierModel));
@@ -127,22 +200,35 @@ public PaddleOcrAll(Model detectorModel, Model? classifierModel, Model recognize
: Math.Min(_options.PreprocessWorkerCount, Environment.ProcessorCount);
_detector = new PaddleOcrDetector(detectorModel ?? throw new ArgumentNullException(nameof(detectorModel)), _options.Detector,
ResolveDetectorIntraThreads(_options));
- _classifier = classifierModel is null ? null : new PaddleOcrClassifier(classifierModel, _options.Classifier);
- _recIntraOpBase = Parallelism.ResolveRecognizerIntraOp(_lineWorkers);
- // Tail boost ceiling: CompiledModel clamps to 16 anyway. The NHWC
- // kernels are FMA-bound and the detector measured faster at 16 than
- // at 8 on Zen 3, so idle line-worker cores are worth feeding even
- // past the physical-core count.
- _recIntraOpMax = Math.Min(Environment.ProcessorCount, 16);
- _gpuCtcIntraOpThreads = _options.GpuCtcIntraOpThreads == 0
- ? _recIntraOpMax
- : Math.Min(_options.GpuCtcIntraOpThreads, Environment.ProcessorCount);
- _recognizer = new PaddleOcrRecognizer(recognizerModel ?? throw new ArgumentNullException(nameof(recognizerModel)),
- dictionaryUtf8, _options.Recognizer, ownsModel: false,
- _recIntraOpBase);
- _recGpu = OnnxSharp.OcrSessionFactory.IsGpuBackend(_options.Recognizer.Backend);
- _recBatchEffective = _options.HasExplicitRecBatchLines ? _options.RecBatchLines
- : _recGpu ? 16 : _options.RecBatchLines;
+ try
+ {
+ _classifier = classifierModel is null ? null : new PaddleOcrClassifier(classifierModel, _options.Classifier);
+ _recIntraOpBase = Parallelism.ResolveRecognizerIntraOp(_lineWorkers);
+ // 剩余行减少时可使用空闲线程提升识别速度,最高不超过编译模型支持的 16 线程。
+ // NHWC 算子受 FMA 吞吐限制,已有测量表明 SMT 线程也可提供额外计算吞吐。
+ _recIntraOpMax = Math.Min(Environment.ProcessorCount, 16);
+ _gpuCtcIntraOpThreads = _options.GpuCtcIntraOpThreads == 0
+ ? _recIntraOpMax
+ : Math.Min(_options.GpuCtcIntraOpThreads, Environment.ProcessorCount);
+ _recognizer = new PaddleOcrRecognizer(recognizerModel ?? throw new ArgumentNullException(nameof(recognizerModel)),
+ dictionaryUtf8, _options.Recognizer, ownsModel: false,
+ _recIntraOpBase);
+ _recGpu = OnnxSharp.OcrSessionFactory.IsGpuBackend(_options.Recognizer.Backend);
+ _recBatchEffective = _options.HasExplicitRecBatchLines ? _options.RecBatchLines
+ : _recGpu ? 16 : _options.RecBatchLines;
+ // 模型独立且显式传入;仅开启配置不会隐式加载或下载纸张模型。
+ _documentOrientation = documentOrientationModel is not null && _options.UseDocumentOrientationClassification
+ ? new PaddleOcrDocumentOrientationClassifier(documentOrientationModel, _options.DocumentOrientation) : null;
+ }
+ catch
+ {
+ // 构造后续阶段失败时,释放此前已创建的会话和编译结果;调用方拥有的 Model 不在这里释放。
+ _documentOrientation?.Dispose();
+ _recognizer?.Dispose();
+ _classifier?.Dispose();
+ _detector.Dispose();
+ throw;
+ }
}
private static byte[] ReadDictionary(Stream source)
@@ -154,20 +240,78 @@ private static byte[] ReadDictionary(Stream source)
return buffer.ToArray();
}
- /// Runs DET, perspective crop, optional CLS and CTC REC.
+ /// 执行可选整页校正、文本检测、透视裁剪、文本行分类和文字识别。
+ /// 原始输入像素,不会被修改。
+ /// 输入图宽度,单位为像素。
+ /// 输入图高度,单位为像素。
+ /// 输入行字节跨度;0 表示紧密排列。
+ /// 输入像素格式,默认 BGR24。
+ /// 识别文本、原图坐标框及可选纸张方向结果。
public PaddleOcrResult Run(ReadOnlySpan source, int sourceWidth, int sourceHeight,
int sourceStride = 0, ImagePixelFormat format = ImagePixelFormat.Bgr24)
=> Run(source, sourceWidth, sourceHeight, returnCtcAlignment: false, sourceStride, format);
///
- /// Same as .
- /// asks this call's greedy decode to
- /// keep one per emitted token. It does not
- /// change the graph, the session, or the tensor shape. False leaves
- /// null and allocates no span array.
+ /// 执行完整 OCR,可在解码时保留 CTC 字符对齐,供原图坐标中的字符框估算使用。
///
+ /// 原始输入像素,可含行尾填充,不会被修改。
+ /// 原始输入图宽度,单位为像素。
+ /// 原始输入图高度,单位为像素。
+ /// 是否保留各输出字符的 CTC 时间区间;false 时 CtcSpans 为 null。
+ /// 输入行字节跨度;0 表示按宽度和格式计算紧密排列的跨度。
+ /// 输入像素格式,默认 BGR24。
+ /// 包含方向预测、实际校正角度及原图坐标识别行的结果。
public PaddleOcrResult Run(ReadOnlySpan source, int sourceWidth, int sourceHeight,
bool returnCtcAlignment, int sourceStride = 0, ImagePixelFormat format = ImagePixelFormat.Bgr24)
+ {
+ if (_disposed) throw new ObjectDisposedException(nameof(PaddleOcrAll));
+ if (_documentOrientation is null)
+ return RunCore(source, sourceWidth, sourceHeight, returnCtcAlignment, sourceStride, format);
+ sourceStride = ImagePixels.ResolveStride(sourceWidth, sourceStride, format);
+ if ((long)sourceWidth * sourceHeight > _options.Detector.MaxImagePixels)
+ throw new InvalidOperationException("Source image exceeds MaxImagePixels.");
+ PaddleOcrDocumentOrientationResult orientation = _documentOrientation.Classify(source,
+ sourceWidth, sourceHeight, sourceStride, format);
+ // 方向预测表示顺时针倾转;按相同角度逆时针校正。低置信度只跳过旋转,不丢弃预测结果。
+ int rotation = orientation.Score >= _options.DocumentOrientationThreshold ? orientation.OrientationDegrees : 0;
+ byte[]? rotated = null;
+ try
+ {
+ PaddleOcrResult result;
+ if (rotation == 0) result = RunCore(source, sourceWidth, sourceHeight, returnCtcAlignment, sourceStride, format);
+ else
+ {
+ long started = PipelineProfiler.Enabled ? PipelineProfiler.Now() : 0;
+ // 每次请求独占旋转缓冲区;目标像素紧密排列,因此 RunCore 的 stride 传 0。
+ rotated = PooledArrays.Rent(checked(sourceWidth * sourceHeight * ImagePixels.BytesPerPixel(format)));
+ DocumentRotation.Rotate(source, sourceWidth, sourceHeight, sourceStride, format, rotation, rotated);
+ if (PipelineProfiler.Enabled) PipelineProfiler.Add(PipelineProfiler.DocumentRotate, started);
+ var size = DocumentRotation.Size(sourceWidth, sourceHeight, rotation);
+ result = RunCore(rotated, size.Width, size.Height, returnCtcAlignment, 0, format);
+ // 逐点还原行框并保留阅读顺序,CTC 字符框后续也能正确映射回原图。
+ for (int i = 0; i < result.Lines.Length; i++)
+ result.Lines[i] = DocumentRotation.ToSource(result.Lines[i], sourceWidth, sourceHeight, rotation);
+ }
+ return new PaddleOcrResult
+ {
+ Lines = result.Lines, DetectedCount = result.DetectedCount,
+ DetectorResizedWidth = result.DetectorResizedWidth, DetectorResizedHeight = result.DetectorResizedHeight,
+ DocumentOrientation = orientation, AppliedDocumentRotationDegrees = rotation
+ };
+ }
+ finally { if (rotated is not null) PooledArrays.Return(rotated); }
+ }
+
+ /// 对当前输入图像执行 DET、文本行 CLS 和 REC;本方法不执行整页方向预测或校正。
+ /// 当前图像的像素;可能是原图,也可能是整页旋转后的临时图。
+ /// 当前图像宽度,单位为像素。
+ /// 当前图像高度,单位为像素。
+ /// 是否在识别解码时保留字符对齐信息。
+ /// 当前图像行字节跨度;0 表示紧密排列。
+ /// 当前图像的像素格式。
+ /// 当前图像坐标系中的 OCR 结果;原图坐标还原由外层 Run 负责。
+ private PaddleOcrResult RunCore(ReadOnlySpan source, int sourceWidth, int sourceHeight,
+ bool returnCtcAlignment, int sourceStride, ImagePixelFormat format)
{
if (_disposed) throw new ObjectDisposedException(nameof(PaddleOcrAll));
sourceStride = ImagePixels.ResolveStride(sourceWidth, sourceStride, format);
@@ -721,6 +865,8 @@ public void Dispose()
_detector.Dispose();
_classifier?.Dispose();
_recognizer.Dispose();
+ _documentOrientation?.Dispose();
+ _ownedDocumentOrientationModel?.Dispose();
_ownedModels?.Dispose();
}
}
diff --git a/src/Sdcb.SimdPaddleOCR/PaddleOcrDocumentOrientationClassifier.cs b/src/Sdcb.SimdPaddleOCR/PaddleOcrDocumentOrientationClassifier.cs
new file mode 100644
index 0000000..7544193
--- /dev/null
+++ b/src/Sdcb.SimdPaddleOCR/PaddleOcrDocumentOrientationClassifier.cs
@@ -0,0 +1,126 @@
+using System.Collections.Concurrent;
+using Sdcb.SimdPaddleOCR.OnnxSharp;
+
+namespace Sdcb.SimdPaddleOCR;
+
+/// 在文本检测之前识别整页纸张的 0°、90°、180°、270° 顺时针方向。
+///
+/// 支持并发调用 :共享只读编译模型,每个请求独立租用推理会话和预处理缓冲区。
+/// 本类型只返回方向与置信度;整页旋转由 的第一阶段负责。
+///
+public sealed class PaddleOcrDocumentOrientationClassifier : IDisposable
+{
+ private readonly CompiledModel _compiled; // 共享编译结果,避免每次调用重新解析模型或打包权重。
+ private readonly ConcurrentBag _sessions = []; // 会话独占输入与中间张量,可供并发请求分别租用。
+ private readonly PaddleOcrDocumentOrientationOptions _options;
+ private readonly bool _ownsModel; // 从流加载时拥有模型;直接传入 Model 时由调用方管理模型。
+ private int _pooledCount; // 原子维护池中数量,避免在热路径遍历 ConcurrentBag。
+ private bool _disposed;
+
+ /// 使用已加载的四分类 ONNX 模型创建纸张方向分类器。
+ /// 输入为 1×3×224×224、输出含四个方向分数的模型;调用方负责释放模型。
+ /// 后端、线程与缓冲池配置;为 null 时使用默认配置。
+ public PaddleOcrDocumentOrientationClassifier(Model model, PaddleOcrDocumentOrientationOptions? options = null)
+ : this(model, options, false) { }
+
+ /// 从 ONNX 流加载纸张方向模型,并在分类器释放时释放所加载的模型。
+ /// 从当前位置读取的模型流;流本身仍由调用方负责释放。
+ /// 后端、线程与缓冲池配置;为 null 时使用默认配置。
+ public PaddleOcrDocumentOrientationClassifier(Stream model, PaddleOcrDocumentOrientationOptions? options = null)
+ : this(Model.Load(model ?? throw new ArgumentNullException(nameof(model))), options, true) { }
+
+ /// 统一编译四方向模型、验证输出类别数,并记录模型的释放责任。
+ /// 已加载的四方向模型。
+ /// 可选配置,为 null 时创建默认配置。
+ /// 是否由分类器在构造失败或释放时释放模型。
+ private PaddleOcrDocumentOrientationClassifier(Model model, PaddleOcrDocumentOrientationOptions? options, bool ownsModel)
+ {
+ _options = options ?? new();
+ _ownsModel = ownsModel;
+ try
+ {
+ if (_options.IntraOpThreads is < 0 or > 16 || _options.MaxPooledSessions < 0 || _options.MaxImagePixels < 1)
+ throw new ArgumentOutOfRangeException(nameof(options));
+ // 默认最多使用 4 个 CPU 算子线程;GPU 后端也保留该预算供必要的 CPU 路径使用。
+ int threads = _options.IntraOpThreads == 0 ? Math.Min(Environment.ProcessorCount, 4) : _options.IntraOpThreads;
+ _compiled = new CompiledModel(model, [1, 3, 224, 224], threads);
+ // 两分类 textline_ori 不能用于四方向纸张校正,在实际运行前检查并拒绝这类模型。
+ using InferenceSession request = _compiled.CreateRequest();
+ if (request.OutputShape.ElementCount != 4)
+ {
+ _compiled.Dispose();
+ throw new ArgumentException("Document orientation requires four output classes (0, 90, 180, 270).", nameof(model));
+ }
+ }
+ catch
+ {
+ if (ownsModel) model.Dispose();
+ throw;
+ }
+ }
+
+ /// 对原始像素执行缩放、中心裁剪、RGB 归一化和四方向推理,返回分数最高的方向。
+ /// 按行排列的原始像素,可包含行尾填充;调用期间不会修改此缓冲区。
+ /// 输入图像宽度,单位为像素,必须为正数。
+ /// 输入图像高度,单位为像素,必须为正数。
+ /// 相邻两行首字节的间隔;0 表示按宽度和像素格式计算紧密排列的行跨度。
+ /// 像素格式,支持 BGR24、RGB24、BGRA32、RGBA32,默认 BGR24。
+ /// 原图顺时针方向及对应置信度;方向类别 0、1、2、3 对应 0°、90°、180°、270°。
+ public PaddleOcrDocumentOrientationResult Classify(ReadOnlySpan source, int width, int height,
+ int stride = 0, ImagePixelFormat format = ImagePixelFormat.Bgr24)
+ {
+ if (_disposed) throw new ObjectDisposedException(nameof(PaddleOcrDocumentOrientationClassifier));
+ stride = ImagePixels.ResolveStride(width, stride, format);
+ if ((long)width * height > _options.MaxImagePixels)
+ throw new InvalidOperationException("Source image exceeds MaxImagePixels.");
+ IOcrSession session;
+ if (_sessions.TryTake(out IOcrSession? pooled))
+ {
+ Interlocked.Decrement(ref _pooledCount);
+ session = pooled;
+ }
+ else session = OcrSessionFactory.Create(_compiled, _options.Backend);
+ try
+ {
+ // GPU 会话需要显式确定形状;后端可自行选择 NCHW 或 NHWC 的内部输入布局。
+ session.Reshape([1, 3, 224, 224]);
+ bool profile = PipelineProfiler.Enabled;
+ long started = profile ? PipelineProfiler.Now() : 0;
+ PPOCRPreprocess.DocumentOrientation(source, width, height, stride, session.InputData,
+ session.ResizeWorkspace, session.InputIsNhwc, format);
+ if (profile) PipelineProfiler.Add(PipelineProfiler.DocumentPreprocess, started);
+ started = profile ? PipelineProfiler.Now() : 0;
+ ReadOnlySpan probabilities = session.RunInternal(session.InputData);
+ if (profile) PipelineProfiler.Add(PipelineProfiler.DocumentGraph, started);
+ if (probabilities.Length != 4) throw new InvalidDataException("Expected four document orientation scores.");
+ // 检查全部输出均为有限值;相同分数保留先出现的类别,与通常的 argmax 行为一致。
+ int label = 0;
+ for (int i = 0; i < probabilities.Length; i++)
+ {
+ if (!MathCompat.IsFinite(probabilities[i])) throw new InvalidDataException("Non-finite document orientation score.");
+ if (probabilities[i] > probabilities[label]) label = i;
+ }
+ return new((uint)label, probabilities[label]);
+ }
+ finally
+ {
+ // 即使预处理或推理抛出异常,也归还会话;超出池容量的会话立即释放。
+ if (_disposed || Interlocked.Increment(ref _pooledCount) > _options.MaxPooledSessions)
+ {
+ if (!_disposed) Interlocked.Decrement(ref _pooledCount);
+ session.Dispose();
+ }
+ else _sessions.Add(session);
+ }
+ }
+
+ /// 释放池中的推理会话和编译结果;从流创建时同时释放所拥有的模型。
+ public void Dispose()
+ {
+ if (_disposed) return;
+ _disposed = true;
+ while (_sessions.TryTake(out IOcrSession? session)) session.Dispose();
+ _compiled.Dispose();
+ if (_ownsModel) _compiled.Model.Dispose();
+ }
+}
diff --git a/src/Sdcb.SimdPaddleOCR/PipelineProfiler.cs b/src/Sdcb.SimdPaddleOCR/PipelineProfiler.cs
index 869f29e..559f8b2 100644
--- a/src/Sdcb.SimdPaddleOCR/PipelineProfiler.cs
+++ b/src/Sdcb.SimdPaddleOCR/PipelineProfiler.cs
@@ -35,7 +35,11 @@ internal static class PipelineProfiler
internal const int RecPool = 18; // per-line REC: input scratch ensure (was ArrayPool.Rent)
internal const int RecReshape = 19; // per-line REC: session.Reshape
internal const int DetUnclip = 20; // nested in det_postprocess: convex outward offset
- internal const int StageCount = 21;
+ // 纸张阶段追加在现有编号之后,保留既有性能统计项的索引。
+ internal const int DocumentPreprocess = 21; // 纸张方向:缩放、中心裁剪和归一化。
+ internal const int DocumentGraph = 22; // 纸张方向:四分类图推理。
+ internal const int DocumentRotate = 23; // 纸张方向:整页像素旋转。
+ internal const int StageCount = 24;
internal static readonly string[] StageNames =
[
@@ -44,7 +48,7 @@ internal static class PipelineProfiler
"rec_postprocess", "lines_wall",
"crop_setup", "rec_acquire", "cls_acquire", "rec_decode",
"rec_release", "cls_release", "rec_cache_get", "rec_rent", "rec_pool",
- "rec_reshape", "det_unclip"
+ "rec_reshape", "det_unclip", "document_preprocess", "document_graph", "document_rotate"
];
private static bool s_enabled;
diff --git a/src/models/Sdcb.SimdPaddleOCR.Models.DocumentOrientation/DocumentOrientationModel.cs b/src/models/Sdcb.SimdPaddleOCR.Models.DocumentOrientation/DocumentOrientationModel.cs
new file mode 100644
index 0000000..657ddf4
--- /dev/null
+++ b/src/models/Sdcb.SimdPaddleOCR.Models.DocumentOrientation/DocumentOrientationModel.cs
@@ -0,0 +1,45 @@
+using System;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using Sdcb.SimdPaddleOCR.ModelProvider;
+
+namespace Sdcb.SimdPaddleOCR.Models.DocumentOrientation;
+
+/// 独立的四方向纸张模型包,通过 Provider 显式传给 PaddleOcrAll.Load 或 LoadAsync。
+public static class DocumentOrientationModel
+{
+ /// 提供内嵌的 PP-LCNet_x1_0_doc_ori ONNX 模型及版本信息,不依赖其他 OCR 模型包。
+ public static IPaddleOcrModelProvider Provider { get; } = new ProviderImpl();
+ /// 打开内嵌纸张方向模型的只读流。
+ /// 新的 ONNX 资源流,由调用方负责释放。
+ public static Stream OpenRead() => Provider.OpenRead();
+
+ /// 把程序集内嵌资源适配为通用模型提供方。
+ private sealed class ProviderImpl : IPaddleOcrModelProvider
+ {
+ /// 官方纸张方向模型名称。
+ public string Name => "PP-LCNet_x1_0_doc_ori";
+ /// 模型类型为整页方向分类,用于区别文本行两分类。
+ public PaddleOcrModelKind Kind => PaddleOcrModelKind.DocumentOrientation;
+ /// 模型文件格式;资源已完成独立 ONNX 简化及数值验证。
+ public string Format => "onnx";
+ /// 方向分类不绑定特定识别语言,因此返回 null。
+ public string? LanguageCode => null;
+ /// 固定的 Hugging Face 源模型提交哈希,用于追溯转换来源。
+ public string? Version => "d3b95a6dff5fe8a94f2748e12b61cb26818a0df8";
+ /// 打开当前程序集内嵌的四分类 ONNX 模型。
+ /// 独立的只读资源流,由调用方负责释放。
+ public Stream OpenRead() => typeof(DocumentOrientationModel).Assembly.GetManifestResourceStream(
+ "Sdcb.SimdPaddleOCR.Models.DocumentOrientation.doc_ori.onnx")
+ ?? throw new InvalidOperationException("Embedded document orientation model was not found.");
+ /// 以异步接口打开内嵌模型;本地资源无网络等待,验证取消后直接返回已完成任务。
+ /// 已取消时立即抛出取消异常,不打开资源流。
+ /// 包含新资源流的已完成任务;流由调用方释放。
+ public Task OpenReadAsync(CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ return Task.FromResult(OpenRead());
+ }
+ }
+}
diff --git a/src/models/Sdcb.SimdPaddleOCR.Models.DocumentOrientation/README.md b/src/models/Sdcb.SimdPaddleOCR.Models.DocumentOrientation/README.md
new file mode 100644
index 0000000..a92a64b
--- /dev/null
+++ b/src/models/Sdcb.SimdPaddleOCR.Models.DocumentOrientation/README.md
@@ -0,0 +1,22 @@
+# Sdcb.SimdPaddleOCR.Models.DocumentOrientation
+
+Independent PP-LCNet_x1_0_doc_ori model for whole-page orientation (0°, 90°, 180°, 270°). This package embeds the simplified ONNX model and its conversion/validation manifest. It depends only on `Sdcb.SimdPaddleOCR.ModelProvider`; reference the inference core and your OCR model bundle separately.
+
+```csharp
+using Sdcb.SimdPaddleOCR;
+using Sdcb.SimdPaddleOCR.Models.ChineseV6Tiny;
+using Sdcb.SimdPaddleOCR.Models.DocumentOrientation;
+
+using var ocr = await PaddleOcrAll.LoadAsync(
+ ChineseV6TinyModels.Default,
+ DocumentOrientationModel.Provider,
+ options: null);
+var result = ocr.Run(pixels, width, height);
+Console.WriteLine(result.DocumentOrientation?.OrientationDegrees);
+```
+
+The optional stage runs before text detection. It corrects the page by the predicted angle and returns OCR boxes in the original input coordinates. The core does not depend on this model package. To read the model alone, use `DocumentOrientationModel.OpenRead()` or `DocumentOrientationModel.Provider.OpenReadAsync()`.
+
+The source Paddle inference.json + pdiparams model is exported with Paddle2ONNX, independently simplified using onnxsim (310 → 113 nodes), and numerically compared against original Paddle inference. It uses RGB, short-side resize 256, center crop 224, and ImageNet normalization. The manifest records source revisions, hashes, tool versions and per-case errors.
+
+Source: [PaddlePaddle/PP-LCNet_x1_0_doc_ori](https://huggingface.co/PaddlePaddle/PP-LCNet_x1_0_doc_ori), Apache-2.0. Copyright (c) PaddlePaddle Authors. [Integration, validation and benchmarks](https://github.com/sdcb/SimdPaddleOCR/blob/master/docs/document-orientation.md).
diff --git a/src/models/Sdcb.SimdPaddleOCR.Models.DocumentOrientation/Sdcb.SimdPaddleOCR.Models.DocumentOrientation.csproj b/src/models/Sdcb.SimdPaddleOCR.Models.DocumentOrientation/Sdcb.SimdPaddleOCR.Models.DocumentOrientation.csproj
new file mode 100644
index 0000000..b65d478
--- /dev/null
+++ b/src/models/Sdcb.SimdPaddleOCR.Models.DocumentOrientation/Sdcb.SimdPaddleOCR.Models.DocumentOrientation.csproj
@@ -0,0 +1,17 @@
+
+
+ netstandard2.0
+ enable
+ latest
+ Apache-2.0
+ README.md
+
+
+
+
+
+
+
+
+
+
diff --git a/test/Sdcb.SimdPaddleOCR.GpuBench/Harness.cs b/test/Sdcb.SimdPaddleOCR.GpuBench/Harness.cs
index 081685f..c2f36a5 100644
--- a/test/Sdcb.SimdPaddleOCR.GpuBench/Harness.cs
+++ b/test/Sdcb.SimdPaddleOCR.GpuBench/Harness.cs
@@ -23,6 +23,9 @@ internal static int Usage(int code)
Set SIMD_OCR_GPU_PROF=1 for per-kernel times on --detprof / --recprof.
--detprof [reps]
+ --resource-perf --backend cpu|vulkan --scope orientation|pipeline [--seconds 20] [--out result.json]
+ --orientation [--cpu] [--no-pipeline] [--out result.json]
+ --orientation --profile SIMD_OCR_GPU_PROF=1 per-dispatch timings
--recprof [reps] REC up to the CTC projection
--conc [threads] [backend]
--sgtest [sgsize.spv] caps + gl_SubgroupSize at required size 0/32/64 (default: Shaders/sgsize.spv)
diff --git a/test/Sdcb.SimdPaddleOCR.GpuBench/OrientationBench.cs b/test/Sdcb.SimdPaddleOCR.GpuBench/OrientationBench.cs
new file mode 100644
index 0000000..b6d1b07
--- /dev/null
+++ b/test/Sdcb.SimdPaddleOCR.GpuBench/OrientationBench.cs
@@ -0,0 +1,37 @@
+using System.Text.Json;
+using Sdcb.SimdPaddleOCR.Validation;
+
+namespace Sdcb.SimdPaddleOCR.GpuBench;
+
+/// 方向模型的桌面数值验证、耗时测试和 Vulkan 调度分析入口。
+internal static class OrientationBench
+{
+ /// 执行方向测试或 GPU 逐调度计时,并将验证报告写入 JSON。
+ /// 命令行参数:--cpu 选择 CPU,--no-pipeline 跳过完整 OCR,--out 指定输出,--profile 分析 GPU。
+ /// 成功返回 0,验证失败时抛出异常。
+ internal static int Run(string[] args)
+ {
+ if (args.Contains("--profile"))
+ {
+ using Stream stream = Models.DocumentOrientation.DocumentOrientationModel.OpenRead();
+ using OnnxSharp.Model model = OnnxSharp.Model.Load(stream);
+ var compiled = new OnnxSharp.CompiledModel(model, [1, 3, 224, 224], 1);
+ try
+ {
+ using var graph = new Backends.Vulkan.GpuDetGraph(Backends.Vulkan.GpuBackend.TryGetDevice()!, compiled);
+ float[] input = new float[3 * 224 * 224];
+ // 先预热 shader 和缓冲区,再统计每次 GPU 调度的耗时。
+ for (int i = 0; i < 8; i++) graph.Run([1, 3, 224, 224], input);
+ graph.DumpProfile([1, 3, 224, 224]);
+ }
+ finally { compiled.Dispose(); }
+ return 0;
+ }
+ OcrBackend backend = args.Contains("--cpu") ? OcrBackend.Cpu : OcrBackend.Vulkan;
+ DocumentOrientationReport result = DocumentOrientationValidation.Run(backend, pipeline: !args.Contains("--no-pipeline"));
+ string output = args.SkipWhile(a => a != "--out").Skip(1).FirstOrDefault() ?? $"bench-out/orientation-{backend}.json";
+ Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(output))!);
+ File.WriteAllText(output, JsonSerializer.Serialize(result, OrientationJsonContext.Default.DocumentOrientationReport));
+ return 0;
+ }
+}
diff --git a/test/Sdcb.SimdPaddleOCR.GpuBench/Program.cs b/test/Sdcb.SimdPaddleOCR.GpuBench/Program.cs
index 4c36010..14f46c4 100644
--- a/test/Sdcb.SimdPaddleOCR.GpuBench/Program.cs
+++ b/test/Sdcb.SimdPaddleOCR.GpuBench/Program.cs
@@ -5,6 +5,8 @@
return args[0] switch
{
+ "--resource-perf" => ResourceBench.Run(args),
+ "--orientation" => OrientationBench.Run(args),
"--sgtest" or "--rawbench" => DeviceBench.Run(args),
"--argmax" or "--argmaxu" => ArgMaxBench.Run(args),
"--ops" or "--graph" or "--arena" => InspectBench.Run(args),
diff --git a/test/Sdcb.SimdPaddleOCR.GpuBench/ResourceBench.cs b/test/Sdcb.SimdPaddleOCR.GpuBench/ResourceBench.cs
new file mode 100644
index 0000000..d93faf9
--- /dev/null
+++ b/test/Sdcb.SimdPaddleOCR.GpuBench/ResourceBench.cs
@@ -0,0 +1,393 @@
+using System.Diagnostics;
+using System.Reflection;
+using System.Runtime.InteropServices;
+using System.Text;
+using System.Text.Json;
+using Microsoft.Win32;
+using Sdcb.SimdPaddleOCR.Models.ChineseV6Tiny;
+using Sdcb.SimdPaddleOCR.Models.DocumentOrientation;
+using Sdcb.SimdPaddleOCR.OnnxSharp;
+using SixLabors.ImageSharp;
+using SixLabors.ImageSharp.PixelFormats;
+
+namespace Sdcb.SimdPaddleOCR.GpuBench;
+
+/// Windows/NVIDIA 上的持续负载测试,记录调用耗时、CPU/GPU 占用和内存/显存。
+internal static class ResourceBench
+{
+ /// 预热后连续执行单请求方向识别或完整 OCR,同时采样资源并写入 JSON 报告。
+ /// 参数含 --backend cpu|vulkan、--scope orientation|pipeline、--seconds 测量秒数及 --out 输出路径。
+ /// 成功返回 0;后端回落、结果变化或采样失败时抛出异常。
+ internal static int Run(string[] args)
+ {
+ if (!OperatingSystem.IsWindows()) throw new PlatformNotSupportedException("Windows telemetry required.");
+ string scope = Argument(args, "--scope", "pipeline");
+ if (scope is not ("pipeline" or "orientation")) throw new ArgumentException("Unknown scope.");
+ OcrBackend backend = Argument(args, "--backend", "cpu") switch
+ {
+ "cpu" => OcrBackend.Cpu,
+ "vulkan" => OcrBackend.Vulkan,
+ _ => throw new ArgumentException("Backend must be cpu or vulkan.")
+ };
+ double seconds = double.Parse(Argument(args, "--seconds", "20"), System.Globalization.CultureInfo.InvariantCulture);
+ if (seconds < 2 || seconds > 300) throw new ArgumentOutOfRangeException(nameof(seconds));
+ string output = Argument(args, "--out", $"bench-out/resource-{scope}-{backend}.json");
+ using var gpu = new NvidiaTelemetry();
+ using var process = Process.GetCurrentProcess();
+ var baseline = new List();
+ // 模型加载前记录空闲基线;GPU 是整卡数值,基线用于说明桌面及其他程序的后台负载。
+ using (var sampler = new ResourceSampler(gpu, process, baseline)) Thread.Sleep(2500);
+
+ using Stream imageStream = typeof(ResourceBench).Assembly.GetManifestResourceStream("orientation.sample.jpg")!;
+ using Image image = Image.Load(imageStream);
+ byte[] original = new byte[image.Width * image.Height * 3], pixels = new byte[original.Length];
+ image.CopyPixelDataTo(original);
+ // 构造顺时针 90° 的输入页,使完整流水线确实执行非零方向的整页校正。
+ DocumentRotation.Rotate(original, image.Width, image.Height, image.Width * 3, ImagePixelFormat.Bgr24, 270, pixels);
+ int width = image.Height, height = image.Width;
+ using Stream modelStream = DocumentOrientationModel.OpenRead();
+ using var orientation = scope == "orientation"
+ ? new PaddleOcrDocumentOrientationClassifier(modelStream, new() { Backend = backend, IntraOpThreads = 4 }) : null;
+ using var pipeline = scope == "pipeline"
+ ? PaddleOcrAll.Load(ChineseV6TinyModels.Default, DocumentOrientationModel.Provider, new()
+ {
+ DocumentOrientation = new() { Backend = backend, IntraOpThreads = 4 },
+ Detector = new() { Backend = backend },
+ Classifier = new() { Backend = backend },
+ Recognizer = new() { Backend = backend },
+ DetIntraOpThreads = 8, LineWorkerCount = 4,
+ PreprocessWorkerCount = 4, GpuCtcIntraOpThreads = 4
+ }) : null;
+ PaddleOcrResult? lastResult = null;
+ PaddleOcrDocumentOrientationResult lastOrientation = default;
+ // 执行一次所选模式,保存最终输出供测量前后校验;文件读取和图像解码不在此调用内。
+ void Invoke()
+ {
+ if (orientation != null) lastOrientation = orientation.Classify(pixels, width, height);
+ else lastResult = pipeline!.Run(pixels, width, height);
+ }
+ // 预热至少 3 秒且不少于 10 次,排除首次 JIT、shader 编译和缓冲区创建。
+ var warmup = Stopwatch.StartNew();
+ int warmupCalls = 0;
+ do { Invoke(); warmupCalls++; } while (warmup.Elapsed.TotalSeconds < 3 || warmupCalls < 10);
+ warmup.Stop();
+ StageState[] before = CheckSessions(orientation, pipeline, backend);
+ CheckResult(lastResult, lastOrientation, scope);
+ ulong? expectedHash = lastResult?.PackedTextHash;
+ Console.WriteLine($"Ready {scope}/{backend} {width}x{height}, warmup={warmupCalls}, sessions={string.Join(", ", before.Select(x => x.Name + ":" + x.SessionTypes))}");
+
+ var samples = new List();
+ var latencies = new List();
+ TimeSpan cpuStarted = process.TotalProcessorTime;
+ long started = Stopwatch.GetTimestamp();
+ using (var sampler = new ResourceSampler(gpu, process, samples))
+ {
+ do
+ {
+ long callStarted = Stopwatch.GetTimestamp();
+ Invoke();
+ latencies.Add(Stopwatch.GetElapsedTime(callStarted).TotalMilliseconds);
+ } while (Stopwatch.GetElapsedTime(started).TotalSeconds < seconds);
+ }
+ double elapsed = Stopwatch.GetElapsedTime(started).TotalSeconds;
+ double cpuSeconds = (process.TotalProcessorTime - cpuStarted).TotalSeconds;
+ StageState[] after = CheckSessions(orientation, pipeline, backend);
+ CheckResult(lastResult, lastOrientation, scope);
+ if (expectedHash != lastResult?.PackedTextHash) throw new InvalidOperationException("OCR text changed during measurement.");
+ double[] sorted = latencies.Order().ToArray();
+ // 进程 CPU 时间跨线程累加,再按整机逻辑处理器数归一化;一个逻辑处理器满载约为 100/N%。
+ double processCpu = cpuSeconds / elapsed / Environment.ProcessorCount * 100;
+ var report = new
+ {
+ CapturedAt = DateTimeOffset.Now, Backend = backend.ToString(), Scope = scope,
+ Hardware = new { Cpu = Registry.GetValue(@"HKEY_LOCAL_MACHINE\HARDWARE\DESCRIPTION\System\CentralProcessor\0", "ProcessorNameString", "unknown"),
+ LogicalProcessors = Environment.ProcessorCount, Gpu = gpu.Name, Driver = gpu.Driver, TotalGpuMemoryMiB = gpu.TotalMemoryMiB,
+ TotalSystemMemoryMiB = Native.Memory().TotalPhys / 1048576.0 },
+ Input = new { File = "examples/sample.jpg", Width = width, Height = height, ClockwiseDegrees = 90, Format = "Bgr24", Lines = lastResult?.Lines.Length,
+ TextHash = lastResult?.PackedTextHash.ToString("x16"), Label = scope == "orientation" ? lastOrientation.OrientationDegrees : lastResult!.DocumentOrientation!.Value.OrientationDegrees },
+ Settings = new { Concurrency = 1, OrientationThreads = 4, DetThreads = 8, LineWorkers = 4, PreprocessWorkers = 4, GpuCtcThreads = 4,
+ WarmupSeconds = warmup.Elapsed.TotalSeconds, WarmupCalls = warmupCalls, SampleIntervalMs = 250, IncludesImageDecoding = false, IncludesModelLoading = false,
+ IncludesPreprocessing = true, CtcAlignment = false, CpuPercentDenominator = "all logical processors", GpuUtilizationScope = "whole NVIDIA GPU, including other applications" },
+ Measurement = new { Seconds = elapsed, Calls = latencies.Count, ThroughputPerSecond = latencies.Count / elapsed,
+ MeanMs = latencies.Average(), MedianMs = Percentile(sorted, .5), P95Ms = Percentile(sorted, .95), MinMs = sorted[0], MaxMs = sorted[^1],
+ ProcessCpuMeanPercent = processCpu, ProcessCpuCoreEquivalents = cpuSeconds / elapsed },
+ Resources = Summarize(samples), Baseline = Summarize(baseline), SessionsBefore = before, SessionsAfter = after,
+ Samples = samples, BaselineSamples = baseline
+ };
+ Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(output))!);
+ File.WriteAllText(output, JsonSerializer.Serialize(report, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, WriteIndented = true }));
+ Console.WriteLine($"{scope}/{backend}: mean={latencies.Average():F3}ms p50={Percentile(sorted, .5):F3}ms p95={Percentile(sorted, .95):F3}ms calls={latencies.Count} cpu={processCpu:F1}% GPU={samples.Average(s => s.GpuPercent):F1}% RAM={samples.Average(s => s.WorkingSetMiB):F1}MiB VRAM={samples.Average(s => s.GpuMemoryMiB):F1}MiB -> {output}");
+ return 0;
+ }
+
+ /// 读取紧随指定命令行选项后的字符串值。
+ /// 原始命令行参数数组。
+ /// 需要查找的选项名称。
+ /// 未提供该选项时返回的默认值。
+ /// 选项值或默认值;选项后缺少值时抛出异常。
+ private static string Argument(string[] args, string key, string fallback)
+ {
+ int i = Array.IndexOf(args, key);
+ return i < 0 ? fallback : i + 1 < args.Length ? args[i + 1] : throw new ArgumentException($"Missing {key} value.");
+ }
+
+ /// 验证测试图的 90° 标签,以及完整 OCR 确实完成旋转并输出文本行。
+ /// 完整 OCR 输出;仅方向测试时为 null。
+ /// 独立方向分类输出;完整 OCR 模式使用 result 中的方向信息。
+ /// orientation 表示独立分类,pipeline 表示完整 OCR。
+ private static void CheckResult(PaddleOcrResult? result, PaddleOcrDocumentOrientationResult orientation, string scope)
+ {
+ if ((scope == "orientation" ? orientation.OrientationDegrees : result!.DocumentOrientation!.Value.OrientationDegrees) != 90)
+ throw new InvalidOperationException("Incorrect orientation label.");
+ if (scope == "pipeline" && (result!.Lines.Length == 0 || result.AppliedDocumentRotationDegrees != 90))
+ throw new InvalidOperationException("OCR failed to correct the page.");
+ }
+
+ /// 检查实际执行过的池化会话,确认 CPU 模式纯用 CPU,Vulkan 模式没有回落。
+ /// 独立分类器;完整 OCR 模式为 null。
+ /// 完整 OCR 引擎;独立分类模式为 null。
+ /// 要求实际使用的显式后端。
+ /// 各阶段实际会话类型、数量及 GPU 有效状态。
+ private static StageState[] CheckSessions(PaddleOcrDocumentOrientationClassifier? orientation, PaddleOcrAll? pipeline, OcrBackend backend)
+ {
+ List<(string Name, object Stage)> stages = [];
+ if (orientation != null) stages.Add(("orientation", orientation));
+ else foreach (string field in new[] { "_documentOrientation", "_detector", "_classifier", "_recognizer" })
+ {
+ object? stage = typeof(PaddleOcrAll).GetField(field, BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(pipeline);
+ if (stage != null) stages.Add((field.TrimStart('_'), stage));
+ }
+ return stages.Select(s =>
+ {
+ var sessions = ((IEnumerable)s.Stage.GetType().GetField("_sessions", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(s.Stage)!).ToArray();
+ if (sessions.Length == 0) throw new InvalidOperationException($"No measured sessions: {s.Name}");
+ bool actualGpu = sessions.All(x => x is not InferenceSession && x.GpuAlive);
+ bool actualCpu = sessions.All(x => x is InferenceSession);
+ if (backend == OcrBackend.Vulkan ? !actualGpu : !actualCpu) throw new InvalidOperationException($"Backend mismatch/fallback: {s.Name}");
+ return new StageState(s.Name, sessions.Length, string.Join(",", sessions.Select(x => x.GetType().Name).Distinct()), actualGpu);
+ }).ToArray();
+ }
+
+ /// 从已排序的非空耗时数组中按向上取整的索引取得分位值。
+ /// 从小到大排序的非空测量数组。
+ /// 分位比例,范围 0~1,例如 0.95 表示 P95。
+ /// 数组中对应的分位测量值。
+ private static double Percentile(double[] sorted, double p) => sorted[(int)Math.Ceiling((sorted.Length - 1) * p)];
+ /// 按全部采样点汇总资源指标的平均值、最小值和采样峰值。
+ /// 非空的资源采样列表。
+ /// 可序列化的指标汇总对象。
+ private static object Summarize(List values)
+ {
+ // selector 指定单个资源指标;峰值只覆盖采样点,不声称捕获两次采样间的瞬时峰值。
+ object Range(Func selector) => new { Mean = values.Average(selector), Min = values.Min(selector), Max = values.Max(selector) };
+ return new
+ {
+ SampleCount = values.Count, ProcessCpuPercent = Range(s => s.ProcessCpuPercent), SystemCpuPercent = Range(s => s.SystemCpuPercent),
+ ProcessWorkingSetMiB = Range(s => s.WorkingSetMiB), ProcessPrivateMiB = Range(s => s.PrivateMiB), ProcessMemoryPercent = Range(s => s.ProcessMemoryPercent),
+ SystemMemoryPercent = Range(s => s.SystemMemoryPercent), GpuUtilizationPercent = Range(s => s.GpuPercent),
+ GpuMemoryMiB = Range(s => s.GpuMemoryMiB), GpuMemoryPercent = Range(s => s.GpuMemoryPercent),
+ GpuClockMHz = Range(s => s.GpuClockMHz), GpuPowerWatts = Range(s => s.GpuPowerWatts), GpuTemperatureC = Range(s => s.GpuTemperatureC)
+ };
+ }
+
+ /// 单个 OCR 阶段实际使用的会话状态。
+ /// 阶段名称。
+ /// 池中已创建并使用的会话数。
+ /// 实际会话实现类型名称。
+ /// 全部会话是否为有效 GPU 会话。
+ private record StageState(string Name, int Sessions, string SessionTypes, bool GpuAlive);
+ /// 单次资源采样,CPU 和 GPU 的统计范围分别由字段明确区分。
+ /// 距本轮采样开始的秒数。
+ /// 测试进程 CPU 占整机逻辑处理器容量的百分比。
+ /// 整机非空闲 CPU 时间的百分比。
+ /// 测试进程物理工作集,单位 MiB。
+ /// 测试进程私有提交内存,单位 MiB。
+ /// 进程工作集占系统总物理内存的百分比。
+ /// 系统报告的整机内存使用百分比。
+ /// 整张 NVIDIA 显卡的忙碌百分比,包含其他程序。
+ /// 整卡已用显存,单位 MiB,包含其他程序。
+ /// 整卡已用显存占总显存的百分比。
+ /// GPU 当前 SM 时钟频率,单位 MHz。
+ /// 整卡功耗,单位瓦。
+ /// GPU 温度,单位摄氏度。
+ private record ResourceSample(double Seconds, double ProcessCpuPercent, double SystemCpuPercent, double WorkingSetMiB,
+ double PrivateMiB, double ProcessMemoryPercent, double SystemMemoryPercent, double GpuPercent, double GpuMemoryMiB,
+ double GpuMemoryPercent, double GpuClockMHz, double GpuPowerWatts, double GpuTemperatureC);
+
+ /// 使用后台线程每 250 毫秒采样一次;释放时等待线程停止并转抛采样异常。
+ private sealed class ResourceSampler : IDisposable
+ {
+ private readonly ManualResetEventSlim _stop = new(false);
+ private readonly Thread _thread;
+ private Exception? _failure;
+ /// 开始资源采样线程。
+ /// 已初始化的 NVML 读取器,必须在采样线程退出后再释放。
+ /// 待测进程对象,供读取 CPU 时间及内存。
+ /// 接收采样结果的列表,采样期间由此线程独占写入。
+ internal ResourceSampler(NvidiaTelemetry gpu, Process process, List samples)
+ {
+ _thread = new Thread(() =>
+ {
+ try
+ {
+ var previousCpu = Native.CpuTimes();
+ TimeSpan previousProcess = process.TotalProcessorTime;
+ var watch = Stopwatch.StartNew();
+ double previousSeconds = 0;
+ while (!_stop.Wait(250))
+ {
+ var currentCpu = Native.CpuTimes();
+ TimeSpan currentProcess = process.TotalProcessorTime;
+ double currentSeconds = watch.Elapsed.TotalSeconds;
+ double processCpu = (currentProcess - previousProcess).TotalSeconds / (currentSeconds - previousSeconds) / Environment.ProcessorCount * 100;
+ // GetSystemTimes 的内核时间含空闲时间;从总时间减去空闲增量才是整机忙碌率。
+ ulong total = currentCpu.Total - previousCpu.Total;
+ double systemCpu = 100.0 * (total - (currentCpu.Idle - previousCpu.Idle)) / total;
+ process.Refresh();
+ var memory = Native.Memory();
+ var util = gpu.Read();
+ samples.Add(new(currentSeconds, processCpu, systemCpu, process.WorkingSet64 / 1048576.0, process.PrivateMemorySize64 / 1048576.0,
+ process.WorkingSet64 * 100.0 / memory.TotalPhys, memory.MemoryLoad, util.Gpu, util.MemoryUsed / 1048576.0,
+ util.MemoryUsed * 100.0 / util.MemoryTotal, util.Clock, util.PowerMilliwatts / 1000.0, util.Temperature));
+ previousCpu = currentCpu; previousProcess = currentProcess; previousSeconds = currentSeconds;
+ }
+ }
+ catch (Exception ex) { _failure = ex; }
+ }) { IsBackground = true, Name = "resource-sampler" };
+ _thread.Start();
+ }
+ /// 停止并等待后台线程退出,确保结果列表可安全读取;采样异常不会被静默忽略。
+ public void Dispose()
+ {
+ _stop.Set(); _thread.Join(); _stop.Dispose();
+ if (_failure != null) throw new InvalidOperationException("Resource sampling failed.", _failure);
+ }
+ }
+
+ /// 仅供 Windows 开发测试使用的 CPU 时间与物理内存接口。
+ private static class Native
+ {
+ /// 对应 Win32 MEMORYSTATUSEX;Length 为结构大小,其余容量字段均为字节。
+ [StructLayout(LayoutKind.Sequential)] internal struct MemoryStatus
+ {
+ public uint Length, MemoryLoad;
+ public ulong TotalPhys, AvailPhys, TotalPageFile, AvailPageFile, TotalVirtual, AvailVirtual, AvailExtendedVirtual;
+ }
+ /// 读取所有逻辑处理器的累计 CPU 时间,时间单位为 100 纳秒。
+ /// 累计空闲时间。
+ /// 累计内核时间,包含 idle。
+ /// 累计用户态时间。
+ /// 读取是否成功;失败时使用 Win32 最后错误码。
+ [DllImport("kernel32.dll", SetLastError = true)] private static extern bool GetSystemTimes(out ulong idle, out ulong kernel, out ulong user);
+ /// 读取整机内存状态。
+ /// 调用前设置 Length,成功后接收总内存、可用内存和使用率。
+ /// 读取是否成功。
+ [DllImport("kernel32.dll", SetLastError = true)] private static extern bool GlobalMemoryStatusEx(ref MemoryStatus status);
+ /// 读取整机累计空闲时间和总 CPU 时间。
+ /// 单位为 100 纳秒的空闲、内核加用户态累计时间。
+ internal static (ulong Idle, ulong Total) CpuTimes()
+ {
+ if (!GetSystemTimes(out ulong idle, out ulong kernel, out ulong user)) throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error());
+ return (idle, kernel + user);
+ }
+ /// 读取物理内存、提交容量及整机内存使用率。
+ /// Win32 内存状态;内存大小字段使用字节。
+ internal static MemoryStatus Memory()
+ {
+ var value = new MemoryStatus { Length = (uint)Marshal.SizeOf() };
+ if (!GlobalMemoryStatusEx(ref value)) throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error());
+ return value;
+ }
+ }
+
+ /// 通过 NVML 读取本机索引为 0 的 NVIDIA 显卡;所有读数均为整卡指标。
+ private sealed class NvidiaTelemetry : IDisposable
+ {
+ private readonly nint _device;
+ /// 显卡名称。
+ internal string Name { get; }
+ /// NVIDIA 驱动版本。
+ internal string Driver { get; }
+ /// 整卡总显存,单位 MiB。
+ internal double TotalMemoryMiB { get; }
+ /// 初始化 NVML,获取首张显卡句柄及固定硬件信息。
+ internal NvidiaTelemetry()
+ {
+ Check(nvmlInit_v2());
+ Check(nvmlDeviceGetHandleByIndex_v2(0, out _device));
+ var buffer = new StringBuilder(128);
+ Check(nvmlDeviceGetName(_device, buffer, 128)); Name = buffer.ToString();
+ buffer.Clear(); Check(nvmlSystemGetDriverVersion(buffer, 128)); Driver = buffer.ToString();
+ Check(nvmlDeviceGetMemoryInfo(_device, out var memory)); TotalMemoryMiB = memory.Total / 1048576.0;
+ }
+ /// 读取当前整卡利用率、显存、SM 时钟、功耗和温度。
+ /// GPU 百分比、已用/总显存字节数、MHz、毫瓦、摄氏度组成的快照。
+ internal (uint Gpu, ulong MemoryUsed, ulong MemoryTotal, uint Clock, uint PowerMilliwatts, uint Temperature) Read()
+ {
+ Check(nvmlDeviceGetUtilizationRates(_device, out var util));
+ Check(nvmlDeviceGetMemoryInfo(_device, out var memory));
+ Check(nvmlDeviceGetClockInfo(_device, 1, out uint clock));
+ Check(nvmlDeviceGetPowerUsage(_device, out uint power));
+ Check(nvmlDeviceGetTemperature(_device, 0, out uint temperature));
+ return (util.Gpu, memory.Used, memory.Total, clock, power, temperature);
+ }
+ /// 结束本测试使用的 NVML 会话。
+ public void Dispose() => Check(nvmlShutdown());
+ /// 将 NVML 的非零返回码转换为显式异常,防止生成无效资源报告。
+ /// NVML 返回码,0 表示成功。
+ private static void Check(int result) { if (result != 0) throw new InvalidOperationException($"NVML failed: {result}"); }
+ // Gpu 为整卡忙碌率,Memory 为显存读写忙碌率,两者均为百分比。
+ [StructLayout(LayoutKind.Sequential)] private struct Utilization { public uint Gpu, Memory; }
+ // Total、Free、Used 分别为整卡总显存、空闲显存和已用显存,单位为字节。
+ [StructLayout(LayoutKind.Sequential)] private struct MemoryInfo { public ulong Total, Free, Used; }
+ /// 初始化 NVML。
+ /// NVML 状态码,0 为成功。
+ [DllImport("nvml.dll", CallingConvention = CallingConvention.Cdecl)] private static extern int nvmlInit_v2();
+ /// 关闭 NVML。
+ /// NVML 状态码,0 为成功。
+ [DllImport("nvml.dll", CallingConvention = CallingConvention.Cdecl)] private static extern int nvmlShutdown();
+ /// 按设备索引取得显卡句柄。
+ /// 零起始的显卡索引。
+ /// 接收显卡句柄。
+ /// NVML 状态码。
+ [DllImport("nvml.dll", CallingConvention = CallingConvention.Cdecl)] private static extern int nvmlDeviceGetHandleByIndex_v2(uint index, out nint device);
+ /// 读取显卡名称。
+ /// 显卡句柄。
+ /// 接收名称的 ANSI 缓冲区。
+ /// 缓冲区容量,含字符串结尾。
+ /// NVML 状态码。
+ [DllImport("nvml.dll", CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)] private static extern int nvmlDeviceGetName(nint device, StringBuilder buffer, uint length);
+ /// 读取驱动版本。
+ /// 接收版本字符串的 ANSI 缓冲区。
+ /// 缓冲区容量,含字符串结尾。
+ /// NVML 状态码。
+ [DllImport("nvml.dll", CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)] private static extern int nvmlSystemGetDriverVersion(StringBuilder buffer, uint length);
+ /// 读取 GPU 及显存读写的利用率。
+ /// 显卡句柄。
+ /// 接收 NVML 利用率采样。
+ /// NVML 状态码。
+ [DllImport("nvml.dll", CallingConvention = CallingConvention.Cdecl)] private static extern int nvmlDeviceGetUtilizationRates(nint device, out Utilization utilization);
+ /// 读取整卡显存容量和占用。
+ /// 显卡句柄。
+ /// 接收显存字节数。
+ /// NVML 状态码。
+ [DllImport("nvml.dll", CallingConvention = CallingConvention.Cdecl)] private static extern int nvmlDeviceGetMemoryInfo(nint device, out MemoryInfo memory);
+ /// 读取指定时钟域的频率。
+ /// 显卡句柄。
+ /// 时钟类型,1 表示 SM 时钟。
+ /// 接收频率,单位 MHz。
+ /// NVML 状态码。
+ [DllImport("nvml.dll", CallingConvention = CallingConvention.Cdecl)] private static extern int nvmlDeviceGetClockInfo(nint device, uint type, out uint clock);
+ /// 读取整卡功耗。
+ /// 显卡句柄。
+ /// 接收功耗,单位毫瓦。
+ /// NVML 状态码。
+ [DllImport("nvml.dll", CallingConvention = CallingConvention.Cdecl)] private static extern int nvmlDeviceGetPowerUsage(nint device, out uint power);
+ /// 读取温度传感器。
+ /// 显卡句柄。
+ /// 传感器编号,0 表示 GPU 核心。
+ /// 接收温度,单位摄氏度。
+ /// NVML 状态码。
+ [DllImport("nvml.dll", CallingConvention = CallingConvention.Cdecl)] private static extern int nvmlDeviceGetTemperature(nint device, uint sensor, out uint temperature);
+ }
+}
diff --git a/test/Sdcb.SimdPaddleOCR.GpuBench/Sdcb.SimdPaddleOCR.GpuBench.csproj b/test/Sdcb.SimdPaddleOCR.GpuBench/Sdcb.SimdPaddleOCR.GpuBench.csproj
index 01aaccf..019877a 100644
--- a/test/Sdcb.SimdPaddleOCR.GpuBench/Sdcb.SimdPaddleOCR.GpuBench.csproj
+++ b/test/Sdcb.SimdPaddleOCR.GpuBench/Sdcb.SimdPaddleOCR.GpuBench.csproj
@@ -13,11 +13,16 @@
+
+
+
+
+
diff --git a/test/Sdcb.SimdPaddleOCR.UnitTests/Assets/Orientation/gemm-column-bias.onnx b/test/Sdcb.SimdPaddleOCR.UnitTests/Assets/Orientation/gemm-column-bias.onnx
new file mode 100644
index 0000000..c47234d
Binary files /dev/null and b/test/Sdcb.SimdPaddleOCR.UnitTests/Assets/Orientation/gemm-column-bias.onnx differ
diff --git a/test/Sdcb.SimdPaddleOCR.UnitTests/Assets/Orientation/gemm-no-bias.onnx b/test/Sdcb.SimdPaddleOCR.UnitTests/Assets/Orientation/gemm-no-bias.onnx
new file mode 100644
index 0000000..2ab41f7
Binary files /dev/null and b/test/Sdcb.SimdPaddleOCR.UnitTests/Assets/Orientation/gemm-no-bias.onnx differ
diff --git a/test/Sdcb.SimdPaddleOCR.UnitTests/Assets/Orientation/gemm-transpose-scale.onnx b/test/Sdcb.SimdPaddleOCR.UnitTests/Assets/Orientation/gemm-transpose-scale.onnx
new file mode 100644
index 0000000..f29619a
Binary files /dev/null and b/test/Sdcb.SimdPaddleOCR.UnitTests/Assets/Orientation/gemm-transpose-scale.onnx differ
diff --git a/test/Sdcb.SimdPaddleOCR.UnitTests/Assets/Orientation/gemm.json b/test/Sdcb.SimdPaddleOCR.UnitTests/Assets/Orientation/gemm.json
new file mode 100644
index 0000000..4b5f75c
--- /dev/null
+++ b/test/Sdcb.SimdPaddleOCR.UnitTests/Assets/Orientation/gemm.json
@@ -0,0 +1,86 @@
+[
+ {
+ "name": "gemm",
+ "shape": [
+ 2,
+ 3
+ ],
+ "input": [
+ -2.0,
+ -1.0,
+ 0.0,
+ 1.0,
+ 2.0,
+ 3.0
+ ],
+ "expected": [
+ -1.0,
+ -0.75,
+ 5.75,
+ 8.25
+ ]
+ },
+ {
+ "name": "gemm-transpose-scale",
+ "shape": [
+ 3,
+ 2
+ ],
+ "input": [
+ -2.0,
+ 1.0,
+ -1.0,
+ 2.0,
+ 0.0,
+ 3.0
+ ],
+ "expected": [
+ -0.5625,
+ 0.375,
+ 4.5,
+ 7.125
+ ]
+ },
+ {
+ "name": "gemm-column-bias",
+ "shape": [
+ 2,
+ 3
+ ],
+ "input": [
+ -2.0,
+ -1.0,
+ 0.0,
+ 1.0,
+ 2.0,
+ 3.0
+ ],
+ "expected": [
+ 0.6875,
+ 1.0625,
+ -2.4375,
+ -3.1875
+ ]
+ },
+ {
+ "name": "gemm-no-bias",
+ "shape": [
+ 2,
+ 3
+ ],
+ "input": [
+ -2.0,
+ -1.0,
+ 0.0,
+ 1.0,
+ 2.0,
+ 3.0
+ ],
+ "expected": [
+ -2.5,
+ -4.0,
+ 11.0,
+ 14.0
+ ]
+ }
+]
\ No newline at end of file
diff --git a/test/Sdcb.SimdPaddleOCR.UnitTests/Assets/Orientation/gemm.onnx b/test/Sdcb.SimdPaddleOCR.UnitTests/Assets/Orientation/gemm.onnx
new file mode 100644
index 0000000..891ecff
Binary files /dev/null and b/test/Sdcb.SimdPaddleOCR.UnitTests/Assets/Orientation/gemm.onnx differ
diff --git a/test/Sdcb.SimdPaddleOCR.UnitTests/Assets/Orientation/preprocess.json b/test/Sdcb.SimdPaddleOCR.UnitTests/Assets/Orientation/preprocess.json
new file mode 100644
index 0000000..93157fc
--- /dev/null
+++ b/test/Sdcb.SimdPaddleOCR.UnitTests/Assets/Orientation/preprocess.json
@@ -0,0 +1,722 @@
+[
+ {
+ "channel": 0,
+ "x": 0,
+ "y": 0,
+ "value": -0.6109256744384766
+ },
+ {
+ "channel": 0,
+ "x": 0,
+ "y": 7,
+ "value": -0.5766761302947998
+ },
+ {
+ "channel": 0,
+ "x": 0,
+ "y": 111,
+ "value": -0.6109256744384766
+ },
+ {
+ "channel": 0,
+ "x": 0,
+ "y": 222,
+ "value": -0.14855730533599854
+ },
+ {
+ "channel": 0,
+ "x": 0,
+ "y": 223,
+ "value": -0.19993162155151367
+ },
+ {
+ "channel": 0,
+ "x": 1,
+ "y": 0,
+ "value": -0.5938009023666382
+ },
+ {
+ "channel": 0,
+ "x": 1,
+ "y": 7,
+ "value": -0.4396780729293823
+ },
+ {
+ "channel": 0,
+ "x": 1,
+ "y": 111,
+ "value": -0.7821731567382812
+ },
+ {
+ "channel": 0,
+ "x": 1,
+ "y": 222,
+ "value": -0.11430788040161133
+ },
+ {
+ "channel": 0,
+ "x": 1,
+ "y": 223,
+ "value": -0.19993162155151367
+ },
+ {
+ "channel": 0,
+ "x": 16,
+ "y": 0,
+ "value": 0.6905555725097656
+ },
+ {
+ "channel": 0,
+ "x": 16,
+ "y": 7,
+ "value": 1.2899219989776611
+ },
+ {
+ "channel": 0,
+ "x": 16,
+ "y": 111,
+ "value": -0.4054286479949951
+ },
+ {
+ "channel": 0,
+ "x": 16,
+ "y": 222,
+ "value": -0.6280504465103149
+ },
+ {
+ "channel": 0,
+ "x": 16,
+ "y": 223,
+ "value": -0.6280504465103149
+ },
+ {
+ "channel": 0,
+ "x": 57,
+ "y": 0,
+ "value": 0.5021834373474121
+ },
+ {
+ "channel": 0,
+ "x": 57,
+ "y": 7,
+ "value": 0.41655945777893066
+ },
+ {
+ "channel": 0,
+ "x": 57,
+ "y": 111,
+ "value": 0.3309357166290283
+ },
+ {
+ "channel": 0,
+ "x": 57,
+ "y": 222,
+ "value": -0.816422700881958
+ },
+ {
+ "channel": 0,
+ "x": 57,
+ "y": 223,
+ "value": -0.7136741876602173
+ },
+ {
+ "channel": 0,
+ "x": 112,
+ "y": 0,
+ "value": -0.3369295597076416
+ },
+ {
+ "channel": 0,
+ "x": 112,
+ "y": 7,
+ "value": 0.39943480491638184
+ },
+ {
+ "channel": 0,
+ "x": 112,
+ "y": 111,
+ "value": -1.3472900390625
+ },
+ {
+ "channel": 0,
+ "x": 112,
+ "y": 222,
+ "value": 0.1425635814666748
+ },
+ {
+ "channel": 0,
+ "x": 112,
+ "y": 223,
+ "value": -0.028684139251708984
+ },
+ {
+ "channel": 0,
+ "x": 173,
+ "y": 0,
+ "value": 0.22818732261657715
+ },
+ {
+ "channel": 0,
+ "x": 173,
+ "y": 7,
+ "value": -0.6794246435165405
+ },
+ {
+ "channel": 0,
+ "x": 173,
+ "y": 111,
+ "value": 1.4611694812774658
+ },
+ {
+ "channel": 0,
+ "x": 173,
+ "y": 222,
+ "value": 0.5364327430725098
+ },
+ {
+ "channel": 0,
+ "x": 173,
+ "y": 223,
+ "value": 0.6220567226409912
+ },
+ {
+ "channel": 0,
+ "x": 222,
+ "y": 0,
+ "value": -0.49105238914489746
+ },
+ {
+ "channel": 0,
+ "x": 222,
+ "y": 7,
+ "value": -0.3369295597076416
+ },
+ {
+ "channel": 0,
+ "x": 222,
+ "y": 111,
+ "value": -0.6622998714447021
+ },
+ {
+ "channel": 0,
+ "x": 222,
+ "y": 222,
+ "value": -0.011559247970581055
+ },
+ {
+ "channel": 0,
+ "x": 222,
+ "y": 223,
+ "value": -0.08005833625793457
+ },
+ {
+ "channel": 0,
+ "x": 223,
+ "y": 0,
+ "value": -0.5253019332885742
+ },
+ {
+ "channel": 0,
+ "x": 223,
+ "y": 7,
+ "value": -0.49105238914489746
+ },
+ {
+ "channel": 0,
+ "x": 223,
+ "y": 111,
+ "value": -0.542426586151123
+ },
+ {
+ "channel": 0,
+ "x": 223,
+ "y": 222,
+ "value": -0.06293344497680664
+ },
+ {
+ "channel": 0,
+ "x": 223,
+ "y": 223,
+ "value": -0.13143253326416016
+ },
+ {
+ "channel": 1,
+ "x": 0,
+ "y": 0,
+ "value": 1.0280113220214844
+ },
+ {
+ "channel": 1,
+ "x": 0,
+ "y": 7,
+ "value": 0.6078431606292725
+ },
+ {
+ "channel": 1,
+ "x": 0,
+ "y": 111,
+ "value": 1.4306724071502686
+ },
+ {
+ "channel": 1,
+ "x": 0,
+ "y": 222,
+ "value": 0.24019598960876465
+ },
+ {
+ "channel": 1,
+ "x": 0,
+ "y": 223,
+ "value": 0.3627450466156006
+ },
+ {
+ "channel": 1,
+ "x": 1,
+ "y": 0,
+ "value": 0.9754903316497803
+ },
+ {
+ "channel": 1,
+ "x": 1,
+ "y": 7,
+ "value": 0.45028018951416016
+ },
+ {
+ "channel": 1,
+ "x": 1,
+ "y": 111,
+ "value": 1.5532214641571045
+ },
+ {
+ "channel": 1,
+ "x": 1,
+ "y": 222,
+ "value": 0.3802521228790283
+ },
+ {
+ "channel": 1,
+ "x": 1,
+ "y": 223,
+ "value": 0.4852941036224365
+ },
+ {
+ "channel": 1,
+ "x": 16,
+ "y": 0,
+ "value": 0.8179271221160889
+ },
+ {
+ "channel": 1,
+ "x": 16,
+ "y": 7,
+ "value": 0.8004202842712402
+ },
+ {
+ "channel": 1,
+ "x": 16,
+ "y": 111,
+ "value": 0.5378150939941406
+ },
+ {
+ "channel": 1,
+ "x": 16,
+ "y": 222,
+ "value": 1.3081233501434326
+ },
+ {
+ "channel": 1,
+ "x": 16,
+ "y": 223,
+ "value": 1.220588207244873
+ },
+ {
+ "channel": 1,
+ "x": 57,
+ "y": 0,
+ "value": -0.004901885986328125
+ },
+ {
+ "channel": 1,
+ "x": 57,
+ "y": 7,
+ "value": -0.0924370288848877
+ },
+ {
+ "channel": 1,
+ "x": 57,
+ "y": 111,
+ "value": -0.0924370288848877
+ },
+ {
+ "channel": 1,
+ "x": 57,
+ "y": 222,
+ "value": 0.4677870273590088
+ },
+ {
+ "channel": 1,
+ "x": 57,
+ "y": 223,
+ "value": 0.39775919914245605
+ },
+ {
+ "channel": 1,
+ "x": 112,
+ "y": 0,
+ "value": -0.8627451658248901
+ },
+ {
+ "channel": 1,
+ "x": 112,
+ "y": 7,
+ "value": -0.0924370288848877
+ },
+ {
+ "channel": 1,
+ "x": 112,
+ "y": 111,
+ "value": 2.288515329360962
+ },
+ {
+ "channel": 1,
+ "x": 112,
+ "y": 222,
+ "value": 1.1155462265014648
+ },
+ {
+ "channel": 1,
+ "x": 112,
+ "y": 223,
+ "value": 1.220588207244873
+ },
+ {
+ "channel": 1,
+ "x": 173,
+ "y": 0,
+ "value": -0.2850140333175659
+ },
+ {
+ "channel": 1,
+ "x": 173,
+ "y": 7,
+ "value": -1.1778712272644043
+ },
+ {
+ "channel": 1,
+ "x": 173,
+ "y": 111,
+ "value": 0.9754903316497803
+ },
+ {
+ "channel": 1,
+ "x": 173,
+ "y": 222,
+ "value": 0.030112028121948242
+ },
+ {
+ "channel": 1,
+ "x": 173,
+ "y": 223,
+ "value": 0.11764717102050781
+ },
+ {
+ "channel": 1,
+ "x": 222,
+ "y": 0,
+ "value": -0.10994398593902588
+ },
+ {
+ "channel": 1,
+ "x": 222,
+ "y": 7,
+ "value": 0.5728292465209961
+ },
+ {
+ "channel": 1,
+ "x": 222,
+ "y": 111,
+ "value": -1.1953781843185425
+ },
+ {
+ "channel": 1,
+ "x": 222,
+ "y": 222,
+ "value": -0.5126050710678101
+ },
+ {
+ "channel": 1,
+ "x": 222,
+ "y": 223,
+ "value": -0.6001400947570801
+ },
+ {
+ "channel": 1,
+ "x": 223,
+ "y": 0,
+ "value": 0.012604951858520508
+ },
+ {
+ "channel": 1,
+ "x": 223,
+ "y": 7,
+ "value": 0.6778712272644043
+ },
+ {
+ "channel": 1,
+ "x": 223,
+ "y": 111,
+ "value": -1.072829246520996
+ },
+ {
+ "channel": 1,
+ "x": 223,
+ "y": 222,
+ "value": -0.5826331377029419
+ },
+ {
+ "channel": 1,
+ "x": 223,
+ "y": 223,
+ "value": -0.6526610851287842
+ },
+ {
+ "channel": 2,
+ "x": 0,
+ "y": 0,
+ "value": 0.600784182548523
+ },
+ {
+ "channel": 2,
+ "x": 0,
+ "y": 7,
+ "value": 0.18248367309570312
+ },
+ {
+ "channel": 2,
+ "x": 0,
+ "y": 111,
+ "value": 1.0016556978225708
+ },
+ {
+ "channel": 2,
+ "x": 0,
+ "y": 222,
+ "value": 1.0190848112106323
+ },
+ {
+ "channel": 2,
+ "x": 0,
+ "y": 223,
+ "value": 1.0016556978225708
+ },
+ {
+ "channel": 2,
+ "x": 1,
+ "y": 0,
+ "value": 0.5484966039657593
+ },
+ {
+ "channel": 2,
+ "x": 1,
+ "y": 7,
+ "value": 0.02562093734741211
+ },
+ {
+ "channel": 2,
+ "x": 1,
+ "y": 111,
+ "value": 1.1236599683761597
+ },
+ {
+ "channel": 2,
+ "x": 1,
+ "y": 222,
+ "value": 0.9493681192398071
+ },
+ {
+ "channel": 2,
+ "x": 1,
+ "y": 223,
+ "value": 0.9493681192398071
+ },
+ {
+ "channel": 2,
+ "x": 16,
+ "y": 0,
+ "value": 0.39163386821746826
+ },
+ {
+ "channel": 2,
+ "x": 16,
+ "y": 7,
+ "value": 0.37420475482940674
+ },
+ {
+ "channel": 2,
+ "x": 16,
+ "y": 111,
+ "value": 0.21734201908111572
+ },
+ {
+ "channel": 2,
+ "x": 16,
+ "y": 222,
+ "value": 0.8796514272689819
+ },
+ {
+ "channel": 2,
+ "x": 16,
+ "y": 223,
+ "value": 0.7925053834915161
+ },
+ {
+ "channel": 2,
+ "x": 57,
+ "y": 0,
+ "value": 0.5659259557723999
+ },
+ {
+ "channel": 2,
+ "x": 57,
+ "y": 7,
+ "value": 1.2456644773483276
+ },
+ {
+ "channel": 2,
+ "x": 57,
+ "y": 111,
+ "value": -0.5146840810775757
+ },
+ {
+ "channel": 2,
+ "x": 57,
+ "y": 222,
+ "value": 0.04305005073547363
+ },
+ {
+ "channel": 2,
+ "x": 57,
+ "y": 223,
+ "value": -0.026666641235351562
+ },
+ {
+ "channel": 2,
+ "x": 112,
+ "y": 0,
+ "value": 0.4787799119949341
+ },
+ {
+ "channel": 2,
+ "x": 112,
+ "y": 7,
+ "value": -0.5321133136749268
+ },
+ {
+ "channel": 2,
+ "x": 112,
+ "y": 111,
+ "value": 1.8556863069534302
+ },
+ {
+ "channel": 2,
+ "x": 112,
+ "y": 222,
+ "value": 0.7750762701034546
+ },
+ {
+ "channel": 2,
+ "x": 112,
+ "y": 223,
+ "value": 0.8796514272689819
+ },
+ {
+ "channel": 2,
+ "x": 173,
+ "y": 0,
+ "value": 1.7162526845932007
+ },
+ {
+ "channel": 2,
+ "x": 173,
+ "y": 7,
+ "value": 2.2565579414367676
+ },
+ {
+ "channel": 2,
+ "x": 173,
+ "y": 111,
+ "value": 0.5484966039657593
+ },
+ {
+ "channel": 2,
+ "x": 173,
+ "y": 222,
+ "value": -0.2532461881637573
+ },
+ {
+ "channel": 2,
+ "x": 173,
+ "y": 223,
+ "value": -0.14867103099822998
+ },
+ {
+ "channel": 2,
+ "x": 222,
+ "y": 0,
+ "value": 0.6705011129379272
+ },
+ {
+ "channel": 2,
+ "x": 222,
+ "y": 7,
+ "value": 0.14762520790100098
+ },
+ {
+ "channel": 2,
+ "x": 222,
+ "y": 111,
+ "value": 1.2979520559310913
+ },
+ {
+ "channel": 2,
+ "x": 222,
+ "y": 222,
+ "value": 1.0539432764053345
+ },
+ {
+ "channel": 2,
+ "x": 222,
+ "y": 223,
+ "value": 1.071372389793396
+ },
+ {
+ "channel": 2,
+ "x": 223,
+ "y": 0,
+ "value": 0.6705011129379272
+ },
+ {
+ "channel": 2,
+ "x": 223,
+ "y": 7,
+ "value": 0.25220048427581787
+ },
+ {
+ "channel": 2,
+ "x": 223,
+ "y": 111,
+ "value": 1.1410893201828003
+ },
+ {
+ "channel": 2,
+ "x": 223,
+ "y": 222,
+ "value": 1.0888017416000366
+ },
+ {
+ "channel": 2,
+ "x": 223,
+ "y": 223,
+ "value": 1.071372389793396
+ }
+]
\ No newline at end of file
diff --git a/test/Sdcb.SimdPaddleOCR.UnitTests/DocumentOrientationTests.cs b/test/Sdcb.SimdPaddleOCR.UnitTests/DocumentOrientationTests.cs
new file mode 100644
index 0000000..dabc7d2
--- /dev/null
+++ b/test/Sdcb.SimdPaddleOCR.UnitTests/DocumentOrientationTests.cs
@@ -0,0 +1,148 @@
+using System.Reflection;
+using System.Text.Json;
+using Sdcb.SimdPaddleOCR.Models.DocumentOrientation;
+using Sdcb.SimdPaddleOCR.OnnxSharp;
+
+namespace Sdcb.SimdPaddleOCR.UnitTests;
+
+/// 验证纸张方向的算子适配、预处理、旋转、并发及流水线开关。
+public class DocumentOrientationTests
+{
+ /// 打开由独立 Python 工具生成并内嵌到测试程序集的参考资源。
+ /// 不带 orientation. 前缀的资源文件名。
+ /// 新的参考资源流,由测试方法释放。
+ private static Stream Asset(string name) => typeof(DocumentOrientationTests).Assembly
+ .GetManifestResourceStream("orientation." + name) ?? throw new Exception(name);
+
+ /// 验证 Gemm 的转置、缩放、偏置广播,以及 Constant 内部名称与输出边不同的情形。
+ [Fact]
+ public void Gemm_TransposeScalingBroadcastBias_AndConstantNames()
+ {
+ using Stream json = Asset("gemm.json");
+ using JsonDocument cases = JsonDocument.Parse(json);
+ foreach (JsonElement c in cases.RootElement.EnumerateArray())
+ {
+ using Stream stream = Asset(c.GetProperty("name").GetString() + ".onnx");
+ using Model model = Model.Load(stream);
+ int[] shape = c.GetProperty("shape").EnumerateArray().Select(v => v.GetInt32()).ToArray();
+ var compiled = new CompiledModel(model, shape, 1);
+ try
+ {
+ using InferenceSession session = compiled.CreateRequest();
+ float[] input = c.GetProperty("input").EnumerateArray().Select(v => v.GetSingle()).ToArray();
+ float[] expected = c.GetProperty("expected").EnumerateArray().Select(v => v.GetSingle()).ToArray();
+ float[] actual = session.Run(input);
+ KernelCorrectnessTests.AssertClose(expected, actual);
+ }
+ finally { compiled.Dispose(); }
+ }
+ }
+
+ /// 验证不同像素格式和张量布局的中心裁剪归一化与 OpenCV 黄金值一致。
+ /// 输入像素格式,包含三通道和四通道格式。
+ /// 是否使用交错通道的 NHWC 输出布局。
+ [Theory]
+ [InlineData(ImagePixelFormat.Bgr24, false)]
+ [InlineData(ImagePixelFormat.Rgb24, false)]
+ [InlineData(ImagePixelFormat.Bgra32, true)]
+ [InlineData(ImagePixelFormat.Rgba32, true)]
+ public void CenterCropRgbNormalization_MatchesOpenCv(ImagePixelFormat format, bool nhwc)
+ {
+ const int width = 17, height = 19;
+ int bpp = ImagePixels.BytesPerPixel(format), stride = width * bpp + 7;
+ byte[] source = new byte[stride * height];
+ for (int y = 0; y < height; y++)
+ for (int x = 0; x < width; x++)
+ for (int c = 0; c < 3; c++)
+ {
+ int sourceChannel = ImagePixels.SwapRedBlue(format) ? 2 - c : c;
+ source[y * stride + x * bpp + c] = (byte)((((y * width + x) * 3 + sourceChannel) * 37 + 11) % 256);
+ }
+ float[] output = new float[3 * 224 * 224];
+ PPOCRPreprocess.DocumentOrientation(source, width, height, stride, output, new ResizeWorkspace(), nhwc, format);
+ using Stream json = Asset("preprocess.json");
+ using JsonDocument samples = JsonDocument.Parse(json);
+ foreach (JsonElement sample in samples.RootElement.EnumerateArray())
+ {
+ int c = sample.GetProperty("channel").GetInt32(), x = sample.GetProperty("x").GetInt32(), y = sample.GetProperty("y").GetInt32();
+ float expected = sample.GetProperty("value").GetSingle();
+ float actual = output[nhwc ? (y * 224 + x) * 3 + c : c * 224 * 224 + y * 224 + x];
+ Assert.True(Math.Abs(expected - actual) <= 3e-7f, $"{format} ({x},{y},{c}): {expected} != {actual}");
+ }
+ }
+
+ /// 验证非正方形、带行尾填充图像的直角旋转及原图坐标逆变换。
+ /// 待测试的逆时针旋转角度。
+ /// 旋转后按行展开时,每个像素对应的原图像素编号。
+ [Theory]
+ [InlineData(90, new int[] { 2, 5, 1, 4, 0, 3 })]
+ [InlineData(180, new int[] { 5, 4, 3, 2, 1, 0 })]
+ [InlineData(270, new int[] { 3, 0, 4, 1, 5, 2 })]
+ public void RectangularRotation_AndSourceCoordinatesAgree(int angle, int[] order)
+ {
+ byte[] source = new byte[2 * 13];
+ for (int y = 0; y < 2; y++) for (int x = 0; x < 3; x++)
+ source[y * 13 + x * 3] = (byte)(y * 3 + x);
+ byte[] rotated = new byte[18];
+ DocumentRotation.Rotate(source, 3, 2, 13, ImagePixelFormat.Bgr24, angle, rotated);
+ Assert.Equal(order, Enumerable.Range(0, 6).Select(i => (int)rotated[i * 3]).ToArray());
+ var size = DocumentRotation.Size(3, 2, angle);
+ for (int i = 0; i < 6; i++)
+ {
+ float x = i % size.Width, y = i / size.Width;
+ var point = DocumentRotation.ToSource(new PaddleOcrDetectionBox(x, y, x, y, x, y, x, y, 1), 3, 2, angle);
+ Assert.Equal(order[i] % 3, point.X1); Assert.Equal(order[i] / 3, point.Y1);
+ }
+ }
+
+ /// 验证模型四分类输出、角度映射及非法图像输入检查。
+ [Fact]
+ public void DocumentModel_HasFourClasses_AndRejectsInvalidInputs()
+ {
+ using Stream stream = DocumentOrientationModel.OpenRead();
+ using var classifier = new PaddleOcrDocumentOrientationClassifier(stream, new() { Backend = OcrBackend.Cpu });
+ Assert.Throws(() => classifier.Classify(new byte[3], 2, 1));
+ Assert.Throws(() => classifier.Classify([], 0, 1));
+ var result = classifier.Classify(new byte[16 * 24 * 3], 16, 24);
+ Assert.InRange(result.Label, 0u, 3u);
+ Assert.InRange(result.Score, 0f, 1f);
+ Assert.Equal((int)result.Label * 90, result.OrientationDegrees);
+ }
+
+ /// 交替并发分类黑白图像,检查各请求不会覆盖共享分类器中的输入缓冲区。
+ [Fact]
+ public void ConcurrentClassification_UsesSeparateRequestBuffers()
+ {
+ using Stream stream = DocumentOrientationModel.OpenRead();
+ using var classifier = new PaddleOcrDocumentOrientationClassifier(stream, new() { Backend = OcrBackend.Cpu, IntraOpThreads = 1 });
+ byte[][] images = [new byte[32 * 48 * 3], Enumerable.Repeat((byte)255, 32 * 48 * 3).ToArray()];
+ var expected = images.Select(image => classifier.Classify(image, 32, 48)).ToArray();
+ Parallel.For(0, 8, i => Assert.Equal(expected[i % 2], classifier.Classify(images[i % 2], 32, 48)));
+ }
+
+ /// 验证独立模型加载遵守取消令牌,并拒绝文本行分类模型提供方。
+ /// 完成两种错误路径验证的异步任务。
+ [Fact]
+ public async Task DocumentProviderLoad_ObservesCancellationAndKind()
+ {
+ using var cancellation = new CancellationTokenSource();
+ cancellation.Cancel();
+ await Assert.ThrowsAnyAsync(() => PaddleOcrAll.LoadAsync(
+ Models.ChineseV6Tiny.ChineseV6TinyModels.Default, DocumentOrientationModel.Provider, null, cancellation.Token));
+ Assert.Throws(() => PaddleOcrAll.Load(Models.ChineseV6Tiny.ChineseV6TinyModels.Default,
+ Models.TextLineOrientation.TextLineOrientationModel.Provider, null));
+ }
+
+ /// 验证关闭纸张阶段时不运行方向图,并返回空方向结果及零校正角度。
+ [Fact]
+ public void OrientationStage_CanBeDisabledWithoutLoadingItIntoTheGraph()
+ {
+ using PaddleOcrAll ocr = PaddleOcrAll.Load(Models.ChineseV6Tiny.ChineseV6TinyModels.Default,
+ DocumentOrientationModel.Provider, new() { UseDocumentOrientationClassification = false,
+ Detector = new() { Backend = OcrBackend.Cpu }, Classifier = new() { Backend = OcrBackend.Cpu },
+ Recognizer = new() { Backend = OcrBackend.Cpu } });
+ var result = ocr.Run(new byte[32 * 48 * 3], 32, 48);
+ Assert.Null(result.DocumentOrientation);
+ Assert.Equal(0, result.AppliedDocumentRotationDegrees);
+ }
+}
diff --git a/test/Sdcb.SimdPaddleOCR.UnitTests/Sdcb.SimdPaddleOCR.UnitTests.csproj b/test/Sdcb.SimdPaddleOCR.UnitTests/Sdcb.SimdPaddleOCR.UnitTests.csproj
index e207a38..89008fd 100644
--- a/test/Sdcb.SimdPaddleOCR.UnitTests/Sdcb.SimdPaddleOCR.UnitTests.csproj
+++ b/test/Sdcb.SimdPaddleOCR.UnitTests/Sdcb.SimdPaddleOCR.UnitTests.csproj
@@ -31,9 +31,11 @@
TargetFramework=netstandard2.0
+
+
diff --git a/test/Sdcb.SimdPaddleOCR.WasmBench/Program.cs b/test/Sdcb.SimdPaddleOCR.WasmBench/Program.cs
index 9134c95..68a8ccc 100644
--- a/test/Sdcb.SimdPaddleOCR.WasmBench/Program.cs
+++ b/test/Sdcb.SimdPaddleOCR.WasmBench/Program.cs
@@ -24,8 +24,24 @@ namespace Sdcb.SimdPaddleOCR.WasmBench;
public static class Program
{
+ /// 运行浏览器性能测试;包含 --orientation 时改为验证纸张四方向模型及完整 OCR。
+ /// 页面传入的命令行参数;--base-url 指向本机测试服务,--out 指定报告文件。
+ /// 成功时返回退出码 0,运行或报告保存失败时抛出异常。
public static async Task Main(string[] args)
{
+ if (args.Contains("--orientation"))
+ {
+ // 浏览器复用桌面的四方向数值、文本及坐标验证;WASM 使用 CPU/SIMD 算子路径。
+ Validation.DocumentOrientationReport result = Validation.DocumentOrientationValidation.Run(OcrBackend.Cpu);
+ string baseAddress = args.SkipWhile(a => a != "--base-url").Skip(1).First();
+ string outFile = args.SkipWhile(a => a != "--out").Skip(1).FirstOrDefault() ?? "bench-out/orientation-wasm.json";
+ using HttpClient http = new() { BaseAddress = new Uri(baseAddress) };
+ // 使用预生成 JSON 上下文兼容浏览器 AOT/裁剪,由本机测试服务保存完整报告。
+ using HttpResponseMessage response = await http.PostAsJsonAsync("/api/save-benchmark?out=" + Uri.EscapeDataString(outFile), result,
+ Validation.OrientationJsonContext.Default.DocumentOrientationReport);
+ response.EnsureSuccessStatusCode();
+ return 0;
+ }
Console.WriteLine("[WASM] SimdPaddleOCR WebAssembly Benchmark Starting...");
int workers = 4;
@@ -162,7 +178,7 @@ public static async Task Main(string[] args)
if (file == "sample.jpg")
{
- using Stream? resStream = typeof(Program).Assembly.GetManifestResourceStream("Sdcb.SimdPaddleOCR.WasmBench.sample.jpg");
+ using Stream? resStream = typeof(Program).Assembly.GetManifestResourceStream("orientation.sample.jpg");
if (resStream is null) throw new InvalidOperationException("sample.jpg not found in embedded resources");
using MemoryStream ms = new MemoryStream();
await resStream.CopyToAsync(ms);
diff --git a/test/Sdcb.SimdPaddleOCR.WasmBench/Sdcb.SimdPaddleOCR.WasmBench.csproj b/test/Sdcb.SimdPaddleOCR.WasmBench/Sdcb.SimdPaddleOCR.WasmBench.csproj
index b29d922..f1d1e51 100644
--- a/test/Sdcb.SimdPaddleOCR.WasmBench/Sdcb.SimdPaddleOCR.WasmBench.csproj
+++ b/test/Sdcb.SimdPaddleOCR.WasmBench/Sdcb.SimdPaddleOCR.WasmBench.csproj
@@ -15,6 +15,7 @@
+
@@ -23,7 +24,9 @@
-
+
+
+
diff --git a/test/Sdcb.SimdPaddleOCR.WasmBench/run.ps1 b/test/Sdcb.SimdPaddleOCR.WasmBench/run.ps1
index 9cc6214..6f6cb1c 100644
--- a/test/Sdcb.SimdPaddleOCR.WasmBench/run.ps1
+++ b/test/Sdcb.SimdPaddleOCR.WasmBench/run.ps1
@@ -1,3 +1,11 @@
+<#
+.SYNOPSIS
+构建并启动浏览器 WebAssembly 性能与方向验证工具。
+.PARAMETER Orientation
+改为运行四方向模型及完整 OCR 的数值、文本、坐标验证,并保存方向报告。
+.PARAMETER BrowserNoSandbox
+仅在受限环境中的本机可信测试启用,向测试浏览器传入 --no-sandbox;默认不启用。
+#>
[CmdletBinding()]
param(
[int]$Workers = 4,
@@ -7,6 +15,8 @@ param(
[switch]$Open,
[switch]$NoBuild,
[switch]$NoAot,
+ [switch]$Orientation,
+ [switch]$BrowserNoSandbox,
# Runtime environment variables, e.g. -Env PPOCR_DET_THREADS=4,PPOCR_NHWC=0
[string[]]$Env = @()
)
@@ -29,6 +39,8 @@ if (-not $NoBuild) {
$ServerScript = Join-Path $ProjectDir "server.mjs"
$NodeArgs = @($ServerScript, "--workers", $Workers, "--model", $Model)
+if ($Orientation) { $NodeArgs += "--orientation" }
+if ($BrowserNoSandbox) { $NodeArgs += "--browser-no-sandbox" }
if ($Count -gt 0) {
$NodeArgs += @("--count", $Count)
}
diff --git a/test/Sdcb.SimdPaddleOCR.WasmBench/server.mjs b/test/Sdcb.SimdPaddleOCR.WasmBench/server.mjs
index ab06620..02ed6d8 100644
--- a/test/Sdcb.SimdPaddleOCR.WasmBench/server.mjs
+++ b/test/Sdcb.SimdPaddleOCR.WasmBench/server.mjs
@@ -153,6 +153,8 @@ server.listen(port, '127.0.0.1', () => {
autorun: '1'
});
for (const pair of envPairs) queryParams.append('env', pair);
+ // 把方向验证开关传入页面,再由页面转为 .NET 的 --orientation 参数。
+ if (argv.includes('--orientation')) queryParams.set('orientation', '1');
const targetUrl = `http://127.0.0.1:${assignedPort}/index.html?${queryParams.toString()}`;
console.log(`[SERVER] WasmBench server running at http://127.0.0.1:${assignedPort}/`);
@@ -174,12 +176,20 @@ server.listen(port, '127.0.0.1', () => {
let browserPath = edgeCandidates.find(p => fs.existsSync(p));
if (!browserPath) browserPath = "msedge";
+ // 使用工作区独立的临时配置目录,避免复用桌面 Edge 进程或写入只读用户配置。
+ const browserScratch = path.join(repoRoot, '.tmp-artifacts');
+ fs.mkdirSync(browserScratch, { recursive: true });
+ const browserProfile = fs.mkdtempSync(path.join(browserScratch, 'wasm-browser-'));
+
const browserArgs = isHeadless
- ? ['--headless=new', '--disable-gpu', '--no-first-run', '--no-default-browser-check', '--log-level=3', targetUrl]
+ ? ['--headless=new', '--disable-gpu', '--no-first-run', '--no-default-browser-check', '--log-level=3', `--user-data-dir=${browserProfile}`, targetUrl]
: [targetUrl];
+ // 仅显式请求时关闭测试浏览器沙箱,用于受限环境里的本机可信页面。
+ if (argv.includes('--browser-no-sandbox')) browserArgs.push('--no-sandbox');
console.log(`[SERVER] Launching ${path.basename(browserPath)} (${isHeadless ? 'Headless' : 'Windowed'})...`);
- const browser = spawn(browserPath, browserArgs, { stdio: isHeadless ? ['ignore', 'ignore', 'ignore'] : 'inherit' });
+ const browser = spawn(browserPath, browserArgs, { windowsHide: isHeadless, stdio: isHeadless ? ['ignore', 'ignore', 'pipe'] : 'inherit' });
+ browser.stderr?.on('data', chunk => process.stderr.write(chunk));
browser.on('error', (err) => {
console.error('[SERVER] Failed to launch browser:', err.message);
diff --git a/test/Sdcb.SimdPaddleOCR.WasmBench/wwwroot/index.html b/test/Sdcb.SimdPaddleOCR.WasmBench/wwwroot/index.html
index 680b26f..34d897d 100644
--- a/test/Sdcb.SimdPaddleOCR.WasmBench/wwwroot/index.html
+++ b/test/Sdcb.SimdPaddleOCR.WasmBench/wwwroot/index.html
@@ -319,6 +319,8 @@ Benchmark Options
"--count", count,
"--out", out
];
+ // orientation 查询参数选择四方向验证入口,复用浏览器 AOT 的运行环境。
+ if (params.has('orientation')) cliArgs.push('--orientation');
log(`[BENCH] Launching with args: ${cliArgs.join(" ")}`, "log-dim");
diff --git a/test/Shared/DocumentOrientationValidation.cs b/test/Shared/DocumentOrientationValidation.cs
new file mode 100644
index 0000000..591f51e
--- /dev/null
+++ b/test/Shared/DocumentOrientationValidation.cs
@@ -0,0 +1,182 @@
+using System.Diagnostics;
+using System.Reflection;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Sdcb.SimdPaddleOCR;
+using Sdcb.SimdPaddleOCR.Models.DocumentOrientation;
+using Sdcb.SimdPaddleOCR.Models.ChineseV6Tiny;
+using Sdcb.SimdPaddleOCR.OnnxSharp;
+using SixLabors.ImageSharp;
+using SixLabors.ImageSharp.PixelFormats;
+
+namespace Sdcb.SimdPaddleOCR.Validation;
+
+/// 桌面与浏览器 WASM 共用的四方向数值、坐标和流水线验证。
+internal static class DocumentOrientationValidation
+{
+ /// 比较独立 Paddle 参考分数,并按后端测试方向推理及可选的完整 OCR。
+ /// 待验证的显式 CPU 或 Vulkan 后端;Vulkan 必须保持 GPU 会话有效。
+ /// 每个线程预算下计时的迭代次数,默认 20,调用方应传入正数。
+ /// 是否同时验证四种方向的文本、原图行框及字符框一致性。
+ /// 包含图推理、预处理耗时、数值误差和流水线结果的报告;验证失败时抛出异常。
+ // 参考分数由 build/convert-orientation.py 独立运行 Paddle 得到,不使用本项目输出生成期望值。
+ internal static DocumentOrientationReport Run(OcrBackend backend, int iterations = 20, bool pipeline = true)
+ {
+ Assembly assembly = typeof(DocumentOrientationValidation).Assembly;
+ using Stream sampleStream = assembly.GetManifestResourceStream("orientation.sample.jpg")!;
+ using Image image = Image.Load(sampleStream);
+ byte[] source = new byte[image.Width * image.Height * 3];
+ image.CopyPixelDataTo(source);
+ using Stream manifestStream = assembly.GetManifestResourceStream("orientation.manifest.json")!;
+ using JsonDocument manifest = JsonDocument.Parse(manifestStream);
+ JsonElement[] expectedCases = manifest.RootElement.GetProperty("models").GetProperty("PP-LCNet_x1_0_doc_ori")
+ .GetProperty("comparisons").EnumerateArray().Where(c => c.GetProperty("case").GetString()!.StartsWith("sample-")).ToArray();
+ using Stream modelStream = DocumentOrientationModel.OpenRead();
+ using Model model = Model.Load(modelStream);
+ List measurements = [];
+ // CPU 比较多个线程预算;GPU 图内并行不由此预算决定,使用一个 CPU 预算即可。
+ int[] budgets = backend == OcrBackend.Cpu ? [1, 2, 4, 8] : [1];
+ double tolerance = backend == OcrBackend.Cpu ? 2e-5 : 0.005;
+ foreach (int budget in budgets)
+ {
+ using CompiledScope scope = new(model, budget);
+ IOcrSession session = scope.Session;
+ if (backend == OcrBackend.Vulkan)
+ {
+ // 用实际 GPU 会话替换默认 CPU 请求,随后检查未发生后端回落。
+ session.Dispose();
+ scope.Session = session = OcrSessionFactory.Create(scope.Compiled, backend);
+ session.Reshape([1, 3, 224, 224]);
+ if (!session.GpuAlive || session is InferenceSession) throw new Exception("Vulkan did not engage.");
+ }
+ double maxError = 0;
+ for (int angle = 0; angle < 360; angle += 90)
+ {
+ // 构造顺时针 angle 的测试图;Rotate 接受逆时针角度,因此使用其补角。
+ int correction = (360 - angle) % 360;
+ byte[] rotated = new byte[source.Length];
+ DocumentRotation.Rotate(source, image.Width, image.Height, image.Width * 3,
+ ImagePixelFormat.Bgr24, correction, rotated);
+ var size = DocumentRotation.Size(image.Width, image.Height, correction);
+ PPOCRPreprocess.DocumentOrientation(rotated, size.Width, size.Height, size.Width * 3,
+ session.InputData, session.ResizeWorkspace, session.InputIsNhwc);
+ float[] output = session.RunInternal(session.InputData).ToArray();
+ float[] expected = expectedCases[angle / 90].GetProperty("scores")[0].EnumerateArray().Select(v => v.GetSingle()).ToArray();
+ for (int c = 0; c < 4; c++) maxError = Math.Max(maxError, Math.Abs(output[c] - expected[c]));
+ int label = Array.IndexOf(output, output.Max());
+ if (label != angle / 90 || maxError > tolerance) throw new Exception($"{backend} angle={angle} label={label} max_abs={maxError:G6}");
+ if (backend == OcrBackend.Vulkan && !session.GpuAlive) throw new Exception("Vulkan fell back during orientation.");
+ }
+ PPOCRPreprocess.DocumentOrientation(source, image.Width, image.Height, image.Width * 3,
+ session.InputData, session.ResizeWorkspace, session.InputIsNhwc);
+ for (int i = 0; i < 3; i++) session.RunInternal(session.InputData);
+ double[] graphMs = new double[iterations], preprocessMs = new double[iterations];
+ for (int i = 0; i < iterations; i++)
+ {
+ long start = Stopwatch.GetTimestamp();
+ PPOCRPreprocess.DocumentOrientation(source, image.Width, image.Height, image.Width * 3,
+ session.InputData, session.ResizeWorkspace, session.InputIsNhwc);
+ preprocessMs[i] = (Stopwatch.GetTimestamp() - start) * 1000.0 / Stopwatch.Frequency;
+ start = Stopwatch.GetTimestamp();
+ session.RunInternal(session.InputData);
+ graphMs[i] = (Stopwatch.GetTimestamp() - start) * 1000.0 / Stopwatch.Frequency;
+ }
+ // 图推理与预处理分别取中位数;模型加载和预热均不计入这些耗时。
+ Array.Sort(graphMs); Array.Sort(preprocessMs);
+ measurements.Add(new(budget, graphMs[iterations / 2], preprocessMs[iterations / 2], maxError,
+ session is not InferenceSession && session.GpuAlive));
+ Console.WriteLine($"orientation {backend} threads={budget} graph={graphMs[iterations / 2]:F3} ms preprocess={preprocessMs[iterations / 2]:F3} ms max_abs={maxError:G6}");
+ }
+ List pipelineCases = [];
+ if (pipeline)
+ {
+ using PaddleOcrAll ocr = PaddleOcrAll.Load(ChineseV6TinyModels.Default, DocumentOrientationModel.Provider,
+ new PaddleOcrOptions { DocumentOrientation = new() { Backend = backend },
+ Detector = new() { Backend = backend }, Classifier = new() { Backend = backend },
+ Recognizer = new() { Backend = backend } });
+ PaddleOcrResult? baseline = null;
+ for (int angle = 0; angle < 360; angle += 90)
+ {
+ int rotation = (360 - angle) % 360;
+ byte[] rotated = new byte[source.Length];
+ DocumentRotation.Rotate(source, image.Width, image.Height, image.Width * 3, ImagePixelFormat.Bgr24, rotation, rotated);
+ var size = DocumentRotation.Size(image.Width, image.Height, rotation);
+ Stopwatch watch = Stopwatch.StartNew();
+ PaddleOcrResult result = ocr.Run(rotated, size.Width, size.Height, returnCtcAlignment: true);
+ if (result.DocumentOrientation?.OrientationDegrees != angle || result.AppliedDocumentRotationDegrees != angle)
+ throw new Exception($"Pipeline orientation failed for {angle}");
+ baseline ??= result;
+ if (result.Text != baseline.Text || result.Lines.Length == 0) throw new Exception($"Pipeline text differs for {angle}");
+ for (int i = 0; i < result.Lines.Length; i++)
+ {
+ // 再逆变换测试图上的结果,与零度原图逐点比较,覆盖行框及 CTC 字符框。
+ PaddleOcrDetectionBox restored = DocumentRotation.ToSource(result.Lines[i].Box, image.Width, image.Height, rotation);
+ for (int p = 0; p < 4; p++)
+ if (System.Numerics.Vector2.Distance(restored[p], baseline.Lines[i].Box[p]) > 0.01f)
+ throw new Exception($"Pipeline coordinates differ for {angle}");
+ PaddleOcrCharacterBox[] chars = result.Lines[i].EstimateCharacterBoxes();
+ PaddleOcrCharacterBox[] originalChars = baseline.Lines[i].EstimateCharacterBoxes();
+ if (chars.Length == 0 || chars.Length != originalChars.Length) throw new Exception("Missing character boxes.");
+ for (int c = 0; c < chars.Length; c++)
+ {
+ var ch = chars[c];
+ var mapped = DocumentRotation.ToSource(new PaddleOcrDetectionBox(ch.X1, ch.Y1, ch.X2, ch.Y2,
+ ch.X3, ch.Y3, ch.X4, ch.Y4, ch.Score), image.Width, image.Height, rotation);
+ var reference = originalChars[c];
+ float[] actualPoints = [mapped.X1, mapped.Y1, mapped.X2, mapped.Y2, mapped.X3, mapped.Y3, mapped.X4, mapped.Y4];
+ float[] expectedPoints = [reference.X1, reference.Y1, reference.X2, reference.Y2, reference.X3, reference.Y3, reference.X4, reference.Y4];
+ for (int p = 0; p < 8; p++)
+ if (Math.Abs(actualPoints[p] - expectedPoints[p]) > 0.01f) throw new Exception($"Character coordinates differ for {angle}");
+ }
+ }
+ pipelineCases.Add(new(angle, result.Lines.Length, result.PackedTextHash, watch.Elapsed.TotalMilliseconds));
+ Console.WriteLine($"orientation pipeline {backend} angle={angle} lines={result.Lines.Length} hash={result.PackedTextHash:x16}");
+ }
+ }
+ return new(backend.ToString(), iterations, measurements.ToArray(), pipelineCases.ToArray(), true);
+ }
+
+ /// 管理一组编译模型和可替换推理会话的生命周期。
+ private sealed class CompiledScope : IDisposable
+ {
+ internal readonly CompiledModel Compiled;
+ internal IOcrSession Session;
+ /// 固定四方向输入形状并创建初始 CPU 请求。
+ /// 借用的方向模型,释放本作用域时不会释放模型本身。
+ /// CPU 算子线程预算。
+ internal CompiledScope(Model model, int threads)
+ {
+ Compiled = new CompiledModel(model, [1, 3, 224, 224], threads);
+ Session = Compiled.CreateRequest();
+ }
+ /// 先释放实际推理会话,再释放编译资源。
+ public void Dispose() { Session.Dispose(); Compiled.Dispose(); }
+ }
+}
+
+/// 可用于桌面和浏览器的方向测试汇总报告。
+/// 实际请求的后端名称。
+/// 每个线程预算下的计时迭代数。
+/// 不同线程预算的推理和预处理测量。
+/// 完整 OCR 四方向测试记录;关闭流水线验证时为空。
+/// 全部验证是否通过;遇到失败时不会生成成功报告。
+internal record DocumentOrientationReport(string Backend, int Iterations, DocumentOrientationMeasurement[] Measurements,
+ DocumentOrientationPipelineCase[] Pipeline, bool Passed);
+/// 单个线程预算的方向图测量。
+/// 编译模型使用的 CPU 算子线程数。
+/// 图推理中位耗时,单位毫秒。
+/// 预处理中位耗时,单位毫秒。
+/// 四个方向预测相对 Paddle 参考概率的最大绝对误差。
+/// 是否实际使用 GPU 会话并且未回落到 CPU。
+internal record DocumentOrientationMeasurement(int Threads, double GraphMedianMs, double PreprocessMedianMs, double MaxAbs, bool GpuAlive);
+/// 单个纸张方向的完整 OCR 验证记录。
+/// 输入纸张的顺时针旋转角度。
+/// 识别出的非空文本行数。
+/// 按固定 UTF-8 格式计算的文本哈希,用于检查方向之间的输出一致性。
+/// 该次完整调用的耗时,单位毫秒;并非稳态性能统计。
+internal record DocumentOrientationPipelineCase(int Angle, int Lines, ulong Hash, double ElapsedMs);
+
+/// 预生成报告序列化代码,避免浏览器 AOT 和裁剪场景依赖反射。
+[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower)]
+[JsonSerializable(typeof(DocumentOrientationReport))]
+internal partial class OrientationJsonContext : JsonSerializerContext { }