
简介这是一份面向文档版面分析开发者的C# OnnxRuntime部署资源围绕DocLayout-YOLO模型提供从模型转换到本地推理的完整工程实现。DocLayout-YOLO基于YOLO-v10借助DocSynth-300K合成数据与全局到局部自适应感知模块可对版式复杂、尺度差异大的文档元素进行实时鲁棒检测资源展示了在C#环境下加载模型、组织输入输出并完成推理调用的方式适合需要将文档结构识别集成到业务系统的中高级.NET开发者。压缩包共325个文件、约463MB主要包含2个onnx模型、9个C#源码文件与sln解决方案还有64个dll依赖库、xml配置、说明文档和样例图片等便于按目录快速定位模型、代码与依赖。已有329人学习下载资源内附完整项目结构与模型配置可直接参考工程代码完成环境搭建、模型加载及推理调用减少重复踩坑。1. 把文档版面识别模型塞进 C# 上位机先从这份 rar 里说清能拿到什么拿到手的是一个 C# OnnxRuntime 部署 DocLayout-YOLO 的压缩包工程这应该是不少做资料数字化、档案识别、票据自动归档的团队最熟悉的交付形态。DocLayout-YOLO 是面向文档版面分析的检测模型能识别标题、正文、图片、表格、公式等区域比直接整页 OCR 再找位置要省事得多。用 OnnxRuntime 做推理意味着不依赖 Python 环境、不要求目标机器装显卡一个 .NET 程序集就能把模型跑起来特别适合 C# 上位机、WPF 桌面端或内网服务集成。这篇笔记按实际交付路径来写先从模型原理和选型讲清楚再给转换脚本、C# 推理封装、后处理和避坑清单最后补一套可视化验证方法。适合要把版面分析做成 Windows 桌面功能或 OCR 前置模块的 .NET 工程师。2. DocLayout-YOLO 的检测原理与 OnnxRuntime 的选型理由2.1 版面分析到底需要模型输出什么文档版面分析的任务是把一张扫描件或者 PDF 渲染图切分成若干语义区域模型输出的不是一行行文字而是多个带类别标签的矩形框。以 DocLayout-YOLO 常用的类别体系来看一般包含 title、plain text、abandon、figure、figure_caption、table、table_caption、table_footnote、isolate_formula、formula_caption 这些类型。实际落地时客户更关心的是表格和图片有没有被完整框出来标题层级是否正确公式区域是否被误判成正文。这个模型最核心的价值在于把检测和分类合并到一次前向推理里完成。相比先做 OCR 再判读文字布局的做法YOLO 系列模型天然输出框坐标和类别概率后处理只做阈值过滤和 NMS整个流程在 CPU 上也能控制在百毫秒级。对于 C# 上位机这类场景用户不会关心你是用 Python 还是 C 做的推理他们只关心双击 exe 之后能不能把一张 PDF 页面里的表格区单独切出来。我在实际项目里一般把 DocLayout-YOLO 放在 OCR 的前一级作为版面切分器。检测出来的 figure 区域直接走图像处理管线table 区域单独送表格识别模型plain text 区域再交 OCR 按行识别。这样每个子模型的压力都小准确率也比整页 OCR 后硬解析高。2.2 C# 侧为什么优先选 OnnxRuntimeC# 做 AI 推理的选项其实不少常见的有 ML.NET、TensorFlow.NET、OpenCvSharp 的 DNN 模块以及直接用 OnnxRuntime。它们的边界差异很明显。ML.NET 主要面向结构化数据和常见的图像分类任务对 YOLO 这类带自定义后处理的模型支持并不顺手。OpenCvSharp DNN 能读 ONNX 模型也能做前处理和输出解析但它在 Windows 上分发时总带着一堆原生依赖在工控机和客户现场容易因为缺 VC 运行库翻车。TensorFlow.NET 能跑但包体积偏大模型格式转换也麻烦。OnnxRuntime 在这条链路里属于最稳的选择。NuGet 上有官方维护的 Microsoft.ML.OnnxRuntime 包CPU 版不依赖额外原生库部署时拷几个文件就能跑。它同时支持 GPU 和 CPU EP在 CUDA 环境可用时可以通过 SessionOptions 追加 CUDA 执行提供程序在纯 CPU 环境也能退回到默认 EP。它和 C# 的类型系统交互非常直接输入用 float[]输出也是 float[],这对习惯用托管数组的 C# 开发者几乎没有学习成本。还有一点是 DllImport 风格的 P/Invoke 封装问题。OnnxRuntime 的官方 C# API 把原生层的错误和生命周期封得很好比如 OrtEnv、SessionOptions、RunOptions 这些都实现了 IDisposable在 WinForms 或 WPF 里反复创建和释放 Session 也不会泄露句柄。相比之下手动 P/Invoke 调用 C 接口虽然能减依赖但一旦模型输入维度变化内存布局出错的排查成本极高。2.3 部署链路总览与最小工程结构整个部署路径可以拆成四段模型转换、C# 推理封装、后处理、可视化验证。模型转换负责把 PyTorch 权重导出成 ONNX并确认输出节点名称和形状。C# 推理封装负责读图、Letterbox 缩放、Session 推理。后处理负责把模型输出解码成框坐标、置信度和类别再跑 NMS。可视化验证负责把检测框画回原图对结果做人工确认或自动比对。我在工程实践里常用的目录结构是这样的DocLayoutDeploy/ ├── Models/ │ └── doclayout_yolo.onnx ├── OnnxInference/ │ ├── ImagePreprocessor.cs │ ├── Detector.cs │ ├── PredictionDecoder.cs │ └── Nms.cs ├── Visualizer/ │ └── BoxDrawer.cs └── MainWindow.xamlModels 目录放转换好的 ONNX 文件OnnxInference 目录放推理相关代码Visualizer 目录放绘图工具。这个划分的好处是换模型时只动 Models 目录和 Detector 里输入尺寸参数不需要改界面层。如果后面要接 PDF 渲染或其他来源的图像只需要保证 ImagePreprocessor 输入是 BGR 格式的位图数据就行。3. 从 PyTorch 权重到 ONNX转换脚本与输出张量核对3.1 准备模型权重和依赖环境做这一步前先确认手头有权重文件。DocLayout-YOLO 基于 YOLO 检测头做扩展权重文件一般是通过训练脚本产出的 .pt 文件。转换时需要一个能加载该模型的 Python 环境包含 torch、torchvision 以及对应版本的 ultralytics 或模型自带依赖。如果当前环境缺包转换脚本会直接抛 ModuleNotFoundError这个错误最好处理装好对应包就行。在转换之前还有一个容易被忽略的点源码里可能有自己定义的模型类或后处理逻辑转换脚本要能够 import 到模型定义文件。我一般把权重和模型定义放在同一个目录下再用一个 convert_onnx.py 的脚本做导出避免 sys.path 找不到模块。3.2 固定输入尺寸导出 ONNXDocLayout-YOLO 的检测头输出张量是模型结构决定的导出时要让 ONNX 的输入 shape 匹配训练时的动态分辨率。如果训练时支持多尺度导出时可以将 H、W 设为动态轴但这样做会带来两个问题一是 OnnxRuntime 在某些 CPU 设备上对动态 shape 的优化不到位推理耗时波动大二是 C# 侧预处理必须跟着动态 shape 适配代码复杂度上升。实际部署时我更推荐固定到长边 640 或 832这样后处理解析也简单。下面是核心转换脚本以长边 832 为例import torch import onnx from models.yolo import Model # 按实际模型定义调整 model Model(cfgdoclayout_yolo.yaml, ch3, nc11) ckpt torch.load(doclayout_yolo.pt, map_locationcpu) model.load_state_dict(ckpt[model].float().state_dict()) model.eval() dummy_input torch.randn(1, 3, 832, 832) torch.onnx.export( model, dummy_input, doclayout_yolo.onnx, opset_version12, input_names[images], output_names[output], dynamic_axesNone, )torch.onnx.export 的 dynamic_axes 参数这里传 None表示所有维度都固定。opset_version 用 12 比较保险因为新版 PyTorch 默认导出的 opset 可能包含一些较新的算子OnnxRuntime 在旧版本上不一定支持。这里还有个容易踩的细节很多人喜欢在 export 前调用 model.fuse() 或者 model.half() 来减小体积但 CPU 推理场景下没必要 half融合操作也可能改变检测头输出结构第一次部署时我建议保持原始权重和原始精度导出。3.3 核对输出张量名称与形状导出之后不能直接丢给 C#先用 onnxruntime 的 Python 接口验证一遍输出形状。这一步能省掉后面大量 C# 侧的排查时间因为 C# 端如果拿错了张量名报错信息往往不够直观。import onnxruntime as ort import numpy as np sess ort.InferenceSession(doclayout_yolo.onnx, providers[CPUExecutionProvider]) print([inp.name for inp in sess.get_inputs()]) print([out.name for out in sess.get_outputs()]) dummy np.random.randn(1, 3, 832, 832).astype(np.float32) outs sess.run(None, {images: dummy}) print([o.shape for o in outs])第一次跑这个验证脚本时要重点看输出张量有几个。DocLayout-YOLO 如果原始模型带端到端 NMS转 ONNX 后可能输出多个张量一个是框坐标一个是类别概率甚至还有一个 num_detections 标量。这种输出结构是为 TRT 或 OpenVINO 的集成准备的C# 端用起来反而啰嗦。我习惯在转换时把检测头的前向逻辑只保留到输出原始预测张量形状一般是 [1, 4 num_classes, 8400] 这样的布局后处理统一放到 C# 侧写这样后续调阈值、调 NMS 都不用重新导出模型。如果输出形状是 [1, 8400, 15] 这种说明检测头已经把坐标转置过了C# 解析时直接按行遍历即可。前者需要先做一次维度置换后者则可以直接读取。这个差异务必在验证脚本里确认清楚它是后面 C# 后处理最容易出错的地方之一。4. C# 侧推理封装预处理、会话加载、坐标解码与 NMS4.1 创建 .NET 项目并引入 Microsoft.ML.OnnxRuntime在 Visual Studio 里创建一个 WPF 应用或者控制台项目都可以先装 NuGet 包。推荐在 csproj 里直接声明 PackageReference因为命令行和 CI 场景下更可控。版本号不要写死太老按 NuGet 上最新的稳定版选择即可。Project SdkMicrosoft.NET.Sdk PropertyGroup OutputTypeWinExe/OutputType TargetFrameworknet8.0-windows/TargetFramework UseWPFtrue/UseWPF /PropertyGroup ItemGroup PackageReference IncludeMicrosoft.ML.OnnxRuntime Version* / PackageReference IncludeOpenCvSharp4 Version* / PackageReference IncludeOpenCvSharp4.runtime.win Version* / /ItemGroup /ProjectMicrosoft.ML.OnnxRuntime 这个包是推理核心OpenCvSharp4 用于图像读取和简单的预处理辅助。如果不想引入 OpenCvSharp也可以用 System.Drawing 或 WPF 的 BitmapEncoder 自己实现缩放但 BGR 通道顺序和像素布局的处理会麻烦一些。在 WPF 里直接用 OpenCvSharp 还有个好处Mat 可以直接和 WriteableBitmap 互转后面画框比较省事。4.2 Letterbox 预处理长边缩放与 Padding 补偿YOLO 系列模型的预处理通常要求把输入图像等比缩放到模型输入尺寸剩余部分用灰色填充也就是 Letterbox。这一步直接决定检测框坐标能否正确映射回原图。很多人图省事直接用 Resize 拉伸导致文档里的表格宽高比变形检测框偏移这是最常见的翻车原因之一。下面是一个在 C# 里比较完整的 Letterbox 实现public class LetterboxResult { public Mat Resized { get; set; } public float Ratio { get; set; } public int PadLeft { get; set; } public int PadTop { get; set; } } public LetterboxResult Letterbox(Mat src, int targetSize) { int originalW src.Width; int originalH src.Height; float ratio Math.Min((float)targetSize / originalW, (float)targetSize / originalH); int newW (int)Math.Round(originalW * ratio); int newH (int)Math.Round(originalH * ratio); Mat resized new Mat(); Cv2.Resize(src, resized, new Size(newW, newH), 0, 0, InterpolationFlags.Linear); int padW targetSize - newW; int padH targetSize - newH; int padLeft padW / 2; int padTop padH / 2; Mat canvas new Mat(targetSize, targetSize, MatType.CV_8UC3, new Scalar(114, 114, 114)); resized.CopyTo(canvas[new Rect(padLeft, padTop, newW, newH)]); return new LetterboxResult { Resized canvas, Ratio ratio, PadLeft padLeft, PadTop padTop }; }这段逻辑里有几个关键点。Ratio 取 min(w 方向比例, h 方向比例)保证整张图都落在画布内PadLeft 和 PadTop 记录的是填充偏移量后处理坐标还原时要用到。这里用 OpenCvSharp 的 Mat 类型Resize 默认插值方式选线性插值就够用。如果文档图像本身有白边填充色用 114 是 YOLO 训练时的惯例不要改成纯黑或纯白否则输出置信度可能会下降。4.3 Session 推理与输出张量解析初始化推理会话时要指定模型路径和运行选项。如果目标机器有 N 卡且装了 CUDA可以通过 SessionOptions 添加 CUDA 执行提供程序如果没有保留 CPU 执行提供程序即可。这里有个容易忽略的点如果同时添加了多个提供程序OnnxRuntime 会按添加顺序优先使用第一个可用的所以 CUDA 要写在 CPU 前面。using Ort:: // 实际引用为 Microsoft.ML.OnnxRuntime 命名空间 public class DocLayoutDetector : IDisposable { private readonly InferenceSession _session; private const int InputSize 832; public DocLayoutDetector(string modelPath, bool useGpu false) { var options new SessionOptions(); if (useGpu) { options.AppendExecutionProvider_CUDA(0); } options.AppendExecutionProvider_CPU(); _session new InferenceSession(modelPath, options); } public float[] RunInference(Mat bgrImage) { var letterbox Letterbox(bgrImage, InputSize); float[] input BgrToNormalizedFloat(letterbox.Resized); using var inputTensor OrtValue.CreateTensorValueFromMemory( input, new long[] { 1, 3, InputSize, InputSize }); var inputs new Dictionarystring, OrtValue { { images, inputTensor } }; using var outputs _session.Run(new RunOptions(), inputs, _session.OutputNames); var output outputs.First().GetTensorDataAsSpanfloat().ToArray(); return output; } }BgrToNormalizedFloat 的实现要保证像素值从 HWC 转 CHW并且数值归一化到 0-1。我这里没有立刻写完整实现后面补充。关键点是 OpenCvSharp 读进来的是 HWC 布局模型输入要求 CHW所以遍历顺序要写成 h 外层、w 中层、c 内层再用归一化系数把 0-255 的像素值缩放到 0-1。很多 C# 新手在这里把像素顺序读错导致输出置信度接近 0还去怀疑模型文件有问题实际上只是数据没排对。4.4 从模型输出解码检测框假设模型输出形状是 [1, 4 11, 8400]那么每个候选框的 8400 个通道位置分别存放 x、y、w、h 和 11 个类别的置信度。YOLO 系列新版检测头输出的是中心坐标和宽高不是角点坐标。C# 端拿到原始输出后需要把中心坐标转成左上角和右下角坐标并进行阈值过滤。public class Detection { public float X1 { get; set; } public float Y1 { get; set; } public float X2 { get; set; } public float Y2 { get; set; } public float Score { get; set; } public int ClassId { get; set; } } public ListDetection Decode(float[] output, int numClasses, float confThreshold, float ratio, int padLeft, int padTop) { const int numBoxes 8400; int stride numClasses 4; var detections new ListDetection(); for (int i 0; i numBoxes; i) { int offset i * stride; float confidence 0f; int classId -1; for (int j 0; j numClasses; j) { float score output[offset 4 j]; if (score confidence) { confidence score; classId j; } } if (confidence confThreshold) continue; float cx output[offset]; float cy output[offset 1]; float w output[offset 2]; float h output[offset 3]; float x1 (cx - w / 2f - padLeft) / ratio; float y1 (cy - h / 2f - padTop) / ratio; float x2 (cx w / 2f - padLeft) / ratio; float y2 (cy h / 2f - padTop) / ratio; detections.Add(new Detection { X1 x1, Y1 y1, X2 x2, Y2 y2, Score confidence, ClassId classId }); } return detections; }这段代码里的 offset 计算容易写错。输出布局是每个候选框一行行内依次是坐标和类别得分所以遍历外层应该是候选框索引 i内层取坐标时要固定用 i * stride 作为起始偏移。确认偏移正确最快的方法是随便取一个置信度高的大目标打印它的原始输出前几个 float 值看看是否符合直觉。如果转换脚本导出时做过转置这里的 stride 解析结构也要相应调整。4.5 非极大值抑制与类内遍历解码后通常会得到大量重叠的候选框需要按类别分别做 NMS。同一类的重叠框大多是一个目标的多次预测不同类间的重叠则有可能是一个区域包含两种语义所以按类内 NMS 是常见做法。public static ListDetection NonMaxSuppression(ListDetection detections, float iouThreshold) { var result new ListDetection(); foreach (var group in detections.GroupBy(d d.ClassId)) { var candidates group.OrderByDescending(d d.Score).ToList(); while (candidates.Count 0) { var best candidates[0]; result.Add(best); candidates.RemoveAt(0); candidates candidates.Where(d Iou(best, d) iouThreshold).ToList(); } } return result; }实际实现时用 while 循环配合 List 的 RemoveAt 性能尚可毕竟是推理链路里最不耗时的部分。官方 Curve 里如果要在 WPF 界面里展示耗时可以在这个方法外面加 Stopwatch。如果候选框数量特别大也可以先把低分框过滤掉再进 NMS建议 confThreshold 设在 0.25 到 0.5 之间iouThreshold 设在 0.4 到 0.6 之间。文档场景中表格和标题区域面积大、重叠少阈值不需要像行人检测那样苛刻。5. 部署避坑手册现象、原因、解决5.1 输出张量名与预期不一致导致 Session.Run 报错现象C# 端用 output 作为输出名调用 RunOnnxRuntime 抛异常说找不到指定名称的输出。原因PyTorch 转 ONNX 时没有显式指定 output_names或者模型检测头里带了端到端 NMS导致输出节点被命名为 num_dets、labels 这类名字。解决在 Python 验证脚本里先打印 _session.get_outputs() 的 name 字段以实际输出名为准。C# 端可以直接用 _session.OutputNames 动态获取不要硬编码。如果想让代码更通用就把输出名作为 Detector 构造函数的一个参数传进去换模型时只改配置。5.2 Letterbox 填充偏移没补偿导致框偏到左上角现象检测出的框坐标整体偏小在图像左上角聚集大表格区域框不住。原因预处理时输入图像被填充到 832x832 后模型输出的坐标是相对整张画布的坐标。后处理直接除以缩放比例没有先减去 padLeft 和 padTop坐标自然整体偏移。解决在坐标还原时严格按 (模型坐标 - padding) / scale 的顺序计算。我习惯把 LetterboxResult 整体传给 Decode 方法避免在函数间单独搬运 ratio、pad 等散参数。还有一个简单验证方法用纯色图像测试框应该落在图像对角或者满足等腰三角形的拓扑关系。5.3 CPU 推理耗时偏高但 CPU 占用率低现象单张文档推理耗时 1 秒以上但任务管理器里 CPU 占用只有 30% 左右多核似乎没吃满。原因模型导出的动态轴导致算子优化的分支判断变多OnnxRuntime 的线程池没有充分并行。另一个可能原因是 DllImport 的指令集兼容问题在部分 CPU 上 OnnxRuntime 回退到基础指令集。解决优先固定输入尺寸转 ONNX 时把 dynamic_axes 设为 None。同时可以在 SessionOptions 里设置 IntraOpNumThreads 为物理核数不要超过物理核心数超线程带来的提升有限。如果机器支持 AVX2 或 AVX512可以考虑安装对应的 OnnxRuntime 优化包这是官方按指令集拆分的发行版。排查这部分问题时建议先跑一段纯 CPU 推理时间统计脚本排除模型本身过大的因素。5.4 模型里表格区域检测出来了但小公式区域经常漏掉现象大表格和图片都能查到isolate_formula 这类的区域时有时无小标题偶尔消失。原因模型输入分辨率偏低长边 640 下小公式区域的像素可能不到 10x10。且检测头在一次前向里同时预测大目标和小目标小目标的召回率天然更低。解决把输入长边从 640 提高到 832 或 1024推理耗时通常会增加 40% 到 80%但小目标召回率会有明显提升。还可以通过降低 confThreshold 到 0.2 来召回更多低分框再用 NMS 去除重复。如果这样都不能满足要求说明单模型检测结构的上限摆在这里建议对公式区域单独剪裁放大后做二次检测这是常见的两头堵方案。5.5 C# 运行环境和 OnnxRuntime 版本不匹配进程直接崩溃现象程序启动后加载模型时崩溃或者 ApplicationException 提示找不到 native library。原因NuGet 包和运行环境架构不匹配。最常见的错误是项目编译成 AnyCPU在 x64 机器上运行时加载了 x86 的原生库或者目标机器是 ARM64 架构而默认安装了 win-x64 版本。解决项目平台目标固定为 x64或者为 ARM64 版本单独安装对应的 Microsoft.ML.OnnxRuntime 包。在发布配置里把 Native 目录下的 onnxruntime.dll 一并拷贝进输出目录很多人只拷贝了托管 DLL忽略原生 DLL结果换机器后莫名崩溃。检查方法也很简单在生产环境写启动日志打印 Environment.Is64BitProcess 和 AssemblyInfo 里 OnnxRuntime 的版本号对比测试机器即可。6. 验证与可视化把检测框叠加到原图上看效果6.1 在 WPF 或 WinForms 里绘制检测框推理完成之后最后一步是把检测结果画回原图让用户或者验收方直观确认版面分析是否准确。常见做法是把检测框和类别名一次性绘制在一张 Mat 上再做显示这样最简单直观。public static Mat DrawDetections(Mat image, ListDetection detections, string[] classNames) { var outputImage image.Clone(); foreach (var det in detections) { var color Scalar.RandomColor(); Cv2.Rectangle(outputImage, new Point((int)det.X1, (int)det.Y1), new Point((int)det.X2, (int)det.Y2), color, 2); string label ${classNames[det.ClassId]}: {det.Score:F2}; Cv2.PutText(outputImage, label, new Point((int)det.X1, Math.Max(20, (int)det.Y1 - 5)), HersheyFonts.HersheySimplex, 0.6, color, 1); } return outputImage; }绘制时要注意两个坐标类型。OpenCvSharp 的 Rectangle 用的是 Point 整型坐标直接强转即可但如果检测框边缘超出图像Cv2 不会自动裁切需要先对 x1、y1、x2、y2 做 Clamp。文字绘制时避免 y1 太靠上导致文字画到图像外边加一个 Math.Max 是必要的。WPF 里显示这个 Mat 时首先要转成 BitmapSource或者直接保存成 jpg 后加载到 Image 控件两种方式都可以。6.2 小样本回归验证和验收基线最后想分享一个工作习惯这也是我踩过几次坑之后养成的从部署完成到交付给业务方中间一定要做一次小样本回归验证。准备一组文档图片覆盖标准公文、论文页、扫描书页、含复杂表格的报表页这四种类型每种 5 到 10 张用可视化脚本把检测框画出来后保存成目录逐张人工核对记录误检和漏检数量。比对时重点关注三类错误表格区域是否有大的漏检标题是否被误检为正文或者反了公式区域是否被大框整体吞没。如果错误比例超过目标值优先调整 confThreshold 和 iouThreshold其次考虑放大输入尺寸。只有当阈值调参无法改善时才回到模型层面做微调或数据增广避免把推理侧的问题甩给训练侧。我现在每次部署完模型都会把阈值参数、输入尺寸和对应的耗时、召回结果记录在一个小表格里再连同可视化输出一起发给验收方。这样做的好处是当别人问“这个效果是怎么测出来的”时你能立刻给出一整套参数和结果而不是对着屏幕讲“我觉得效果还行”。希望帮到你。本文还有配套的精品资源点击获取