
简介面向需要在.NET/C#环境中集成DocLayout-YOLO的开发者这份资源提供了一套完整的OnnxRuntime部署方案。DocLayout-YOLO基于YOLO-v10通过DocSynth-300K预训练数据集和全局到局部自适应感知模块可对版式多变的文档元素进行实时鲁棒检测。压缩包共325个文件、463MB以dll运行库、onnx模型、csproj工程配置和xml配置为主附带文本说明、示例图片及调试符号便于对照工程结构梳理推理流程。已有329人学习适合具备基础C#开发经验、希望快速在Windows平台落地文档版面分析功能的工程师。资源内含模型调用示例、依赖库组织方式与常见排错参考可帮助减少环境配置和推理适配的弯路。1. C# 里跑 DocLayout-YOLO为什么桌面端不养 Python把一份 PDF 页面自动切成标题、正文、表格、图块这种需求在文档解析、票据识别和 RPA 项目里几乎天天遇到。DocLayout-YOLO 这类基于 YOLO 的文档版面检测模型正好能把这些区域一次找齐。但客户机器上通常只有 Windows业务方也不可能为了一个功能去装 Python 环境所以 C# OnnxRuntime 部署 DocLayout-YOLO 就成了最现实的方案模型导出成 .onnxC# 程序只依赖几个 NuGet 包就能跑。这篇笔记把我自己的落地流程拆开讲先解决模型导出和输入输出确认再搭 C# 推理工程然后把输出张量转成版面框最后是五个高频坑和性能验证建议。2. 导出 ONNX 与核对模型信息先给 C# 侧立规矩2.1 DocLayout-YOLO 的输出是什么DocLayout-YOLO 的模型本身和普通 YOLO 检测模型没有本质区别它输入一张图像输出一堆候选框、类别置信度和类别索引。文档布局场景里的“类别”常见的是标题、正文、表格、图、页眉页脚这些而不是 COCO 那 80 类。拿到的压缩包解压后一般就是 .onnx 模型文件加一个示例工程但先别急着往 Visual Studio 里拖第一步应该在 Python 里把模型的输入输出形状看清楚。这步没做后面 C# 代码全靠猜十有八九要返工。我先说输出。不导出内置 NMS 的 YOLO 检测模型输出通常是一个三维张量形状要么是 [1, 4类别数, anchor 总数]要么是 [1, anchor 总数, 4类别数]。前一种是 channels-first后一种是 channels-last。4 指的是每个候选框的中心点 x、中心点 y、宽度、高度。类别数由训练数据决定文档布局模型一般是 10 到 20 类。anchor 总数由输入尺寸和 YOLO 的检测头下采样倍数决定固定输入 1024×1024 时三个检测头加起来通常是 21504 个候选框。这个数字不用背运行时把形状打出来就行但要清楚它意味着什么。2.2 用 ultralytics 导出 ONNX 的固定命令我做这个方案的常规做法是用 ultralytics 的 Python 接口导出权重。命令如下from ultralytics import YOLO model YOLO(doclayout_yolo.pt) # 换成你实际拿到的权重文件 model.export( formatonnx, imgsz1024, # 固定推理尺寸文档版面建议 1024 而不是 640 dynamicFalse, # 关掉动态轴C# 侧更好处理 batch1, # 先按单张导出 opset12, # 兼容性优先ORT 版本太老也能跑 simplifyTrue, # 做一遍图优化节点更少 nmsFalse, # 不要内置 NMS后处理自己写 )参数里最重要的两个是dynamicFalse和nmsFalse。dynamicFalse会让导出的 onnx 输入形状固定成 [1, 3, 1024, 1024]C# 侧不用处理动态维度nmsFalse保证输出的是原始预测张量NMS 留在 C# 里根据业务阈值调比模型内置 NMS 灵活得多。至于opset我用 12 是为了兼容老版本 OnnxRuntime如果你确定 C# 侧已经是最新的 ORT改成 17 或 19 问题也不大。simplify能减小模型体积但导出后一定要走下一步打印形状确认输出名没有被改写。2.3 打印输入输出形状确认维度和通道数导出完成后用 onnx 库把输入输出节点的名字和形状打出来import onnx model onnx.load(doclayout_yolo.onnx) for inp in model.graph.input: print(input:, inp.name, [d.dim_value for d in inp.type.tensor_type.shape.dim]) for out in model.graph.output: print(output:, out.name, [d.dim_value for d in out.type.tensor_type.shape.dim])我见过不少人在这一步翻车。如果打印出来是input: images [1, 3, 1024, 1024]说明输入名是images通道顺序是 RGB如果是output: output0 [1, 24, 21504]那么 24 4 20 类21504 128×128 64×64 32×32分别对应三个下采样尺度的 anchor 数。如果 dim_value 里出现 0说明那一维是动态的最好重新导出成固定形状否则 C# 里每次推理都要重新推导维度。这一步打印结果记在注释里比什么都可靠。2.4 类别名文件从 coco80 那套套路切过来做过 YOLO 目标检测的人应该熟悉 coco80 那套类别映射一个 names 文件按行放 80 个类别名代码里按索引读。DocLayout-YOLO 的类别顺序也是同样套路只是类别名换成了文档布局元素。问题在于 ONNX 文件里通常不保存类别名所以你要么从训练配置的 .yaml 里复制要么从模型接受的 labels 文件里读。我一般会把类别名放成一个字符串数组顺序必须和训练时完全一致。顺序错了框的位置再准也没有意义后处理把“表格”标成“页眉”就是这种低级错误。3. C# 工程搭建与推理OnnxRuntime 加 OpenCvSharp 的最小闭环3.1 NuGet 包和工程组织C# 侧我不建议用太重的东西Windows 桌面上最稳的组合是 Microsoft.ML.OnnxRuntime 加 OpenCvSharp4。前者负责模型推理后者负责图像读取、resize 和画框。工程结构我一般这样组织DocLayoutYolo/ ├─ DocLayoutYolo/ │ ├─ Detector.cs │ ├─ BoxResult.cs │ └─ Program.cs └─ models/ └─ doclayout_yolo.onnxDetector.cs 封装模型加载、预处理、推理和后处理对外只暴露一个Detect(Mat image)方法。BoxResult.cs 放检测结果的数据结构比如左上右下坐标、类别索引、置信度。Program.cs 是控制台入口方便单独验证。先把控制台工程跑通再塞进 WinForms 或 WPF 上位机能省掉大量来回调试的麻烦。NuGet 包安装 Microsoft.ML.OnnxRuntime 和 OpenCvSharp4 两个包就够了不需要额外引 Python 相关的东西。3.2 用 SessionOptions 配置 CPU 数和 GPU EP模型加载用 InferenceSessionSessionOptions 里可以提前把线程数和执行提供者配好using Microsoft.ML.OnnxRuntime; var options new SessionOptions { EnableMemoryPattern true, IntraOpNumThreads 4, // 按 CPU 核数调整4 到 8 比较常见 InterOpNumThreads 1 }; // 有 GPU 且装了匹配的 CUDA 环境再打开否则先注释 // options.AppendExecutionProvider_CUDA(0); options.AppendExecutionProvider_CPU(0); _session new InferenceSession(models/doclayout_yolo.onnx, options);执行提供者的注册顺序是有讲究的CUDA 放前面CPU 放后面OR T 会按顺序尝试初始化提供者。CUDA 初始化失败时会自动落到 CPU这既是优点也是隐患后面避坑章节会专门说。IntraOpNumThreads 控制算子内部的并行度文档布局模型推理时主要吃 CPU 单次推理性能4 到 8 线程对大多数桌面机器是合理的InterOpNumThreads 是算子间的并行默认 1 就好调大反而可能增加调度开销。Session 一定要做成单例程序启动时加载一次不要每次推理都 new。3.3 图像预处理letterbox 和 CHW 张量YOLO 类模型要求的预处理是固定的保持宽高比缩放到输入尺寸剩余区域用灰色填充转 RGB归一化到 0 到 1。直接 Resize 会破坏目标比例导致检测框偏移。下面这个方法是常用的 letterbox 实现using System.Runtime.InteropServices; using OpenCvSharp; private static byte[] Preprocess(Mat src, int size, out float scale, out int padX, out int padY) { scale Math.Min((float)size / src.Cols, (float)size / src.Rows); int newW Math.Max(1, (int)Math.Round(src.Cols * scale)); int newH Math.Max(1, (int)Math.Round(src.Rows * scale)); using var resized new Mat(); Cv2.Resize(src, resized, new Size(newW, newH), 0, 0, InterpolationFlags.Linear); using var canvas new Mat(size, size, MatType.CV_8UC3, new Scalar(114, 114, 114)); resized.CopyTo(canvas[new Rect((size - newW) / 2, (size - newH) / 2, newW, newH)]); using var rgb new Mat(); Cv2.CvtColor(canvas, rgb, ColorConversionCodes.BGR2RGB); byte[] data new byte[3 * size * size]; Marshal.Copy(rgb.Data, data, 0, data.Length); padX (size - newW) / 2; padY (size - newH) / 2; return data; }这段代码把原图按比例缩放到 1024×1024 的画布上灰色填充值用 114这是 YOLO 训练时常用的默认填充值。scale是缩放比padX和padY是填充偏移后面映射回原图坐标时要用。返回的data是 RGB 顺序的 HWC 连续内存。注意 OpenCV 默认读进来是 BGR必须先转 RGB否则颜色通道错乱会让置信度整体下降。拿到 HWC 的数据后还要转成 ORT 需要的 CHW 张量并归一化var tensor new DenseTensorfloat(new[] { 1, 3, size, size }); var span tensor.Buffer.Span; for (int c 0; c 3; c) { for (int y 0; y size; y) { for (int x 0; x size; x) { span[(c * size y) * size x] data[(y * size x) * 3 c] / 255f; } } }这里的三重循环是把 HWC 的data重新排列成 CHW 并除以 255。3 个通道乘 1024 乘 1024大约 300 万次操作C# 里跑一次也就几十毫秒瓶颈不在这里。如果你对性能敏感可以用unsafe指针直接按偏移量复制但对大多数桌面应用来说上面的双层循环已经够用。3.4 跑一次推理并检查输出形状预处理完成后调用 InferenceSession 的 Run 方法using var results _session.Run( new ListNamedOnnxValue { NamedOnnxValue.CreateFromTensor(images, tensor) }); var output results[0].AsTensorfloat(); var dims output.Dimensions.ToArray(); Console.WriteLine(string.Join( x , dims));输入名images必须和第二章打印出来的输入节点名一致如果导出时改过名这里要跟着改。results用using包住是为了让 ORT 及时释放输出张量的内存这在长期运行的上位机里很重要。输出张量的值此时还是扁平的浮点数组需要按第 4 章的方式解码成框。打印出来的dims先和 Python 侧对一眼确认是 [1, 24, 21504] 还是 [1, 21504, 24]这一步对了后面才不会被数据布局绕晕。4. 把输出张量变成版面框解码、NMS 与类别映射4.1 两种输出排布读之前先猜后验ONNX 推理结果本身不关心你习惯的坐标格式它只是一串数字。常见的两种排布channels-first 是 [1, 4nc, anchors]channels-last 是 [1, anchors, 4nc]。如果用 ultralytics 默认导出的 v8 模型多数情况是 channels-first。但不同版本、不同导出工具可能不同所以解码前先写一段临时代码打印前几个值看是不是合理的坐标数值。正中心点 cx、cy 应该在 0 到输入尺寸之间宽度和高度应该是正数。看到值合理了再写死布局别凭印象硬编码。4.2 解码候选框从中心点还原到左上右下下面这段是按 channels-first 写的解码逻辑按你自己的 dims 调整访问下标int numClasses dims[1] - 4; int anchors dims[2]; var boxes new Listfloat[](); // x1, y1, x2, y2 var scores new Listfloat(); for (int a 0; a anchors; a) { float cx output[0, 0, a]; float cy output[0, 1, a]; float w output[0, 2, a]; float h output[0, 3, a]; float bestScore 0f; int bestClass -1; for (int c 0; c numClasses; c) { float s output[0, 4 c, a]; if (s bestScore) { bestScore s; bestClass c; } } if (bestScore 0.25f) continue; boxes.Add(new float[] { cx - w / 2f, cy - h / 2f, cx w / 2f, cy h / 2f }); scores.Add(bestScore); }YOLO 的检测头输出的是相对于输入图像的像素坐标不是归一化坐标所以这里直接把 cx、cy、w、h 换算成 x1、y1、x2、y2。如果你打印出来发现 cx 都在 0.5 附近那说明模型输出的是归一化坐标要先乘以输入尺寸再换算。置信度阈值 0.25 是我常用的起点文档版面检测里目标比较大可以适当调到 0.3 到 0.4 来减少误检。这里的bestClass就是类别索引后处理时用它查类别名数组。4.3 NMS 去重和回原图坐标同一个版面元素可能被多个相邻 anchor 命中不压掉重复框输出会很难看。用 OpenCvSharp 自带的 NMS 方法就行Cv2.Dnn.NMSBoxes( boxes.Select(b new Rect2d(b[0], b[1], b[2] - b[0], b[3] - b[1])).ToArray(), scores.ToArray(), 0.25f, // scoreThreshold和前面解码时保持一致 0.45f, // nmsThreshold越大保留的框越多 out int[] indices);NMSBoxes按置信度降序贪心去重对文档版面这种稀疏场景完全够用。如果你的 OpenCvSharp 版本不接受Rect2d重载用Rect并对坐标取整也可以误差最多一两个像素。拿到indices后把保留的框映射回原图坐标float x1 (b[0] - padX) / scale; float y1 (b[1] - padY) / scale; float x2 (b[2] - padX) / scale; float y2 (b[3] - padY) / scale;这里scale和padX、padY就是第三章预处理时记录下来的。减掉灰色填充的偏移再除以缩放比例得到的坐标才能真正画在原图上。最后一件事是把坐标 clamp 到原图边界防止某些框因为 padding 溢出到负坐标。4.4 输出 JSON 或画框交给上位机消费对文档解析项目来说后续任务通常是按布局块做 OCR 或者提取表格所以最合理的输出格式是 JSON。用 System.Text.Json 直接序列化即可var json JsonSerializer.Serialize(boxes.Select((b, i) new { Label ClassNames[indices[i]], Score Math.Round(scores[indices[i]], 4), Rect new { X Math.Round(x1, 2), Y Math.Round(y1, 2), Width Math.Round(x2 - x1, 2), Height Math.Round(y2 - y1, 2) } }), options);ClassNames数组按第二章整理的类别顺序填好。上位机拿到 JSON 后可以做版面树、按坐标裁剪图片、再扔给 OCR这一步和部署本身解耦。如果你这一步只想先肉眼看效果直接用Cv2.Rectangle和Cv2.PutText把框画出来存成 PNG验证速度比看 JSON 快得多。5. 部署避坑记录五个让 C# 推理翻车的细节5.1 没按 letterbox 预处理框集体向右下偏现象是检测框整体偏移页面边缘的小字漏检而且框越大偏得越明显。原因在于直接Cv2.Resize把非正方形的 PDF 页面硬压成 1024×1024破坏了 YOLO 训练时保持宽高比的习惯。解决方法是严格走第三章的 letterbox 流程把 scale 和 pad 记下来回映射时减 pad 再除 scale。验证方法是拿一张正方形测试图跑一遍框应该和原图完全重合。5.2 输出维度顺序读反置信度一片混乱同一张图 Python 侧推理正常C# 侧输出全是不正常的分数或者框的位置横七竖八。原因是模型的输出排布不是你以为的那个顺序你按 [1, 4nc, anchors] 访问时实际数据可能是 [1, anchors, 4nc]。解决方法是先打印输出形状再用第一个 anchor 的第 0、4、5 个通道确认是不是 cx 和分数。这一步属于血泪经验越早确认越省时间。5.3 C# 和 Python 推理结果对不上这是最常见的“明明同一份模型结果却不一样”的情况。原因有三个颜色通道没转 RGB、插值方式不一样、归一化方式不一致。Python 侧预处理用 Pillow 的 RGB BicubicC# 侧用 OpenCV 的 BGR Linear差一点都会让置信度波动。解决方法是把预处理公式固定下来RGB、uint8 读取、Linear 插值、值域 0 到 1。C# 和 Python 各跑同一张图取前 32×32 像素对比误差小于 1e-3 再继续。5.4 CUDA EP 初始化失败直接崩在 session 创建现象是调用AppendExecutionProvider_CUDA(0)后new InferenceSession 抛异常或者提示找不到 cudnn64_8.dll。原因是你只引了 Microsoft.ML.OnnxRuntime 这个 CPU 包GPU EP 需要单独的 Microsoft.ML.OnnxRuntime.Gpu 包而且 CUDA/cuDNN 版本要和 ORT 匹配这个匹配关系属于环境玄学版本越新越容易踩坑。解决方法是先只保留 CPU EP 跑通整条链路要上 GPU 再换 Gpu 包装一次匹配的 CUDA/cuDNN不要同时混装 CPU 和 GPU 两个包。5.5 每次推理都 new InferenceSession又慢又涨内存现象是程序第一次点按钮卡两秒处理完一百页内存持续上涨。原因是 new InferenceSession 会完整加载模型、初始化执行提供者和内存规划这个开销一次就有几百毫秒再加上 Run 返回的 OrtValue 没释放内存自然只增不减。解决方法是把 session 做成静态单例程序启动时预热一次Run 的结果用 using 包住让 ORT 输出张量随作用域释放。这也是上位机长时间运行的底线要求。6. 性能与验证GPU 加速、批处理与一页纸验收清单6.1 从 CPU 到 CUDA EP 的切换如果单张 1024×1024 的文档图在 CPU 上要 300 到 500 毫秒而你需要在产线上跑多路那就该考虑 GPU。切换代码很简单var options new SessionOptions(); options.AppendExecutionProvider_CUDA(0); options.AppendExecutionProvider_CPU(0); var session new InferenceSession(doclayout_yolo.onnx, options);CUDA 注册在 CPU 前面OR T 会优先尝试 CUDA EP。前提是安装 Microsoft.ML.OnnxRuntime.Gpu 包并确保本机 CUDA/cuDNN 版本和 ORT 匹配。换了 GPU 之后要预热几次再测耗时第一次推理带着初始化开销数据不可信。6.2 多线程与批处理同一台上位机处理多页文档时我一般每个页面一个任务多个任务并发调用同一个 session 的 Run。OnnxRuntime 的 InferenceSession 在并发调用上是线程安全的但每个线程必须准备自己的输入 DenseTensor不要共享同一个变量。要是单页推理已经满足吞吐没必要上 batch如果确实要压极限可以导出 batch8一次推理处理 8 页但 1024×1024×3×8 的输入就有 25MB算上中间激活值内存很容易上 G低配机器慎用。6.3 一页纸验收清单检查项标准做法通过条件预处理一致性同一张图分别跑 Python 和 C#对前 32×32 像素做 diff误差小于 1e-3框坐标一致性C# 输出与 Python 输出做框级 IoU 对比框数一致IoU 大于 0.8单页耗时预热 3 次后计时按项目预算CPU 300ms 以内可接受内存稳定性连续处理 100 页并监控内存内存曲线不持续上涨类别映射抽查 10 个框的 Label 是否正确标签和版面元素一一对应我自己做这套方案的习惯是先写一个不带界面的控制台工程只把 PDF 页面渲染成 PNG然后在控制台里跑完预处理、推理、后处理、JSON 输出。这一段通了再把它搬进 WPF 或 WinForms 上位机网上那些标注着“部署完成”的模板工程十有八九栽在少了这个验证步骤。希望帮到你。本文还有配套的精品资源点击获取