简介:面向需要在.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 就成了最现实的方案:模型导出成 .onnx,C# 程序只依赖几个 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( format="onnx", imgsz=1024, # 固定推理尺寸,文档版面建议 1024 而不是 640 dynamic=False, # 关掉动态轴,C# 侧更好处理 batch=1, # 先按单张导出 opset=12, # 兼容性优先,ORT 版本太老也能跑 simplify=True, # 做一遍图优化,节点更少 nms=False, # 不要内置 NMS,后处理自己写 )参数里最重要的两个是dynamic=False和nms=False。dynamic=False会让导出的 onnx 输入形状固定成 [1, 3, 1024, 1024],C# 侧不用处理动态维度;nms=False保证输出的是原始预测张量,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
模型加载用 InferenceSession,SessionOptions 里可以提前把线程数和执行提供者配好:
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 DenseTensor<float>(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 List<NamedOnnxValue> { NamedOnnxValue.CreateFromTensor("images", tensor) }); var output = results[0].AsTensor<float>(); 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, 4+nc, anchors],channels-last 是 [1, anchors, 4+nc]。如果用 ultralytics 默认导出的 v8 模型,多数情况是 channels-first。但不同版本、不同导出工具可能不同,所以解码前先写一段临时代码打印前几个值,看是不是合理的坐标数值。正中心点 cx、cy 应该在 0 到输入尺寸之间,宽度和高度应该是正数。看到值合理了再写死布局,别凭印象硬编码。
4.2 解码候选框:从中心点还原到左上右下
下面这段是按 channels-first 写的解码逻辑,按你自己的 dims 调整访问下标:
int numClasses = dims[1] - 4; int anchors = dims[2]; var boxes = new List<float[]>(); // x1, y1, x2, y2 var scores = new List<float>(); 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, 4+nc, anchors] 访问时,实际数据可能是 [1, anchors, 4+nc]。解决方法是先打印输出形状,再用第一个 anchor 的第 0、4、5 个通道确认是不是 cx 和分数。这一步属于血泪经验,越早确认越省时间。
5.3 C# 和 Python 推理结果对不上
这是最常见的“明明同一份模型,结果却不一样”的情况。原因有三个:颜色通道没转 RGB、插值方式不一样、归一化方式不一致。Python 侧预处理用 Pillow 的 RGB + Bicubic,C# 侧用 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;如果确实要压极限,可以导出 batch=8,一次推理处理 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 上位机;网上那些标注着“部署完成”的模板工程,十有八九栽在少了这个验证步骤。希望帮到你。
本文还有配套的精品资源,点击获取