
简介面向文档版面分析开发者这套C# OnnxRuntime部署方案围绕DocLayout-YOLO模型展开解决实际业务中PDF、扫描件等多样性文档的版面检测与结构化解析问题。压缩包共325个文件、463.21MB核心包含onnx模型文件、C#工程源码.cs/.csproj/.sln及配套的依赖库64个dll、33个h、多个onnxruntime原生库另有xml配置文件、txt参数说明与jpg样例图片方便直接编译运行或二次改造。已有327人学习下载。部署Demo覆盖从模型加载、预处理到后处理的全流程并保留了NuGet依赖与跨平台so/dylib等文件适合熟悉YOLO但缺少C#推理经验的入门者快速搭建原型也可为生产环境的文档解析模块提供参考实现。1. 这个“rar”里装的是哪条链路C#、OnnxRuntime和DocLayout-YOLO的关系拿到手的是一个带 .rar 后缀的压缩包说明你已经越过“找模型”这一步直接到了部署环节。DocLayout-YOLO 是文档版面分析用的目标检测模型能在扫描件或 PDF 渲染图里同时框出标题、正文、表格、图片、页眉页脚这几类区域。C# 端用 OnnxRuntime 加载它跑推理最大的价值是省掉一个独立的 Python 识别服务把版面分析直接嵌进桌面工具、上位机软件或者 Windows 服务里。真正耗时间的不是模型推理本身而是三件事把 PyTorch 权重导出成固定 shape 的 ONNX、在 C# 里做 letterbox 预处理、把模型输出的张量解码成矩形坐标并做 NMS。下面按这条链路把每一步的参数和坑位写清楚。2. 模型侧准备把 DocLayout-YOLO 变成 OnnxRuntime 能稳定读取的 ONNX2.1 DocLayout-YOLO 的检测头和输出张量结构DocLayout-YOLO 属于 YOLO 系检测器输出不是直接给坐标而是给出一组“候选框概率分布”。具体来说模型对输入图片做多尺度特征提取最后把三个尺度下的预测结果摊平成一张大表常见形状是[1, 4 num_classes, 8400]其中 8400 是三个 stride8、16、32下所有 anchor 位置的总和4是 cx、cy、w、h 四个坐标值num_classes取决于你手上的权重是在哪个版面数据集上训的。公共文档数据集一般有 13 到 20 类私有数据集可能完全不一样所以拿到 onnx 后不要死记[1, 84, 8400]这个形状84只是 4 80 类的特例。这里有一个和 YOLOv5 系列不同的点DocLayout-YOLO 这类基于 YOLOv8 的模型很多版本没有单独的 objectness 通道输出维度是4 num_classes而如果模型来自 YOLOv5 风格的头输出维度是5 num_classes多出的那个维度是目标度。你不需要先猜拿到 onnx 打印一次维度就能确定后面第 4 章的解码逻辑两种都兼容。2.2 用导出脚本生成固定 shape 的 ONNX 文件模型仓库里一般自带 export 脚本但核心导出逻辑是通用的。我一般不建议用动态 shape 导出的 onnx 直接做 C# 部署因为 OnnxRuntime 的 C# API 在处理动态维度时要么多写很多判断逻辑要么在 CPU 上触发额外的内存重排。DocLayout-YOLO 的输入是 640x640固定成静态 shape 部署最省心。import torch # 模型类以你手上模型仓库的 export.py 为准这里只示意导出逻辑 model load_doclayout_model(doclayout_yolo.pt) model.eval() dummy_input torch.zeros(1, 3, 640, 640) torch.onnx.export( model, dummy_input, doclayout_yolo.onnx, input_names[images], output_names[output], dynamic_axesNone, # 固定所有维度C# 端不用处理动态 shape opset_version17, # 兼容 OnnxRuntime 1.14 以上版本 do_constant_foldingTrue, ) print(export done)这段代码最关键的是dynamic_axesNone和opset_version17。固定 shape 后OnnxRuntime 在 C# 端可以直接用new int[] { 1, 3, 640, 640 }构造输入张量不需要查边界。opset 17 是当前兼容性比较稳的档位OnnxRuntime 1.14 及以上都支持如果你部署机上的 OnnxRuntime 是较老版本建议降到 12但会缺少一些算子的融合优化。导出的 onnx 模型文件一般在 40MB 到 80MB 之间如果你的权重包含大量转置节点导致体积异常偏大检查一下模型头结构是否被重复封装。2.3 核对输入输出名和维度别硬编码字符串C# 端创建InferenceSession时并不强制要求你知道输入名可以通过session.InputMetadata动态取。但为了后面写代码不返工最好在导出后立刻核对一次。import onnx m onnx.load(doclayout_yolo.onnx) for inp in m.graph.input: print(input:, inp.name, [d.dim_value for d in inp.type.tensor_type.shape.dim]) for out in m.graph.output: print(output:, out.name, [d.dim_value for d in out.type.tensor_type.shape.dim])打印结果通常有两种输出排布含义C# 端处理方式[1, 4num_classes, 8400]通道在前候选框在后解码时按列索引取output[0, j, i][1, 8400, 4num_classes]候选框在前通道在后先做一次转置或者改索引顺序两种排布对应的解码循环长得完全不一样所以这个检查不能省。另外还要核对输入名到底叫images还是input还是别的网上很多 C# 示例里写死images换一个模型就报Invalid Feed错误这就是没有动态取输入名的后果。3. C# 工程初始化NuGet 包、图像预处理与 Session 加载3.1 选包CPU 推理和 GPU 推理的包不是同一个C# 侧跑 OnnxRuntime 最常用的 NuGet 包有两组CPU 版Microsoft.ML.OnnxRuntime和 GPU 版Microsoft.ML.OnnxRuntime.Gpu。两个包不能同时引用它们的程序集名称一样装在一起会冲突。如果你的部署环境是普通办公电脑或工控机我建议先用 CPU 版把链路跑通单页 640x640 的推理在主流 i5 上大约 20ms 到 50ms做文档检测完全够用确认有性能瓶颈再切 GPU 版。NuGet 包适用场景注意事项Microsoft.ML.OnnxRuntime纯 CPU无外部依赖部署机不需要装 CUDA体积小Microsoft.ML.OnnxRuntime.GpuCUDA 加速推理需要匹配 CUDA 和 cuDNN 版本部署机要装驱动OpenCvSharp4图像读取、resize、letterbox注意OpenCvSharp4.runtime.win也要一起引用System.Drawing.Common输出结果在 WinForms 里画框仅 Windows 可用Linux 上会抛异常图像处理我一般用 OpenCvSharp 而不是System.Drawing原因是System.Drawing.Bitmap拿像素要逐像素GetPixel速度慢到没法接受而 OpenCvSharp 的Mat支持整块内存拷贝做 resize 和填充也是原生实现。如果你的部署目标是 Linux 容器System.Drawing.Common在 .NET 6 之后的兼容性很麻烦OpenCvSharp 反而更省事。3.2 用 OpenCvSharp 完成 letterbox 和 CHW 内存重排DocLayout-YOLO 训练时的预处理是 letterbox把原图等比缩放到短边贴合 640 边界剩下的区域用 114 灰度填充。这一步有两个信息必须返回缩放倍率scale和填充偏移padW/padH后面把检测框还原到原图坐标时要用。using OpenCvSharp; float[] Preprocess(Mat src, out float scale, out int padW, out int padH) { const int inputSize 640; scale Math.Min((float)inputSize / src.Width, (float)inputSize / src.Height); int newW (int)Math.Round(src.Width * scale); int newH (int)Math.Round(src.Height * scale); Mat resized new Mat(); Cv2.Resize(src, resized, new Size(newW, newH)); using var rgb new Mat(); Cv2.CvtColor(resized, rgb, ColorConversionCodes.BGR2RGB); Mat canvas new Mat(inputSize, inputSize, MatType.CV_8UC3, new Scalar(114, 114, 114)); padW (inputSize - newW) / 2; padH (inputSize - newH) / 2; rgb.CopyTo(canvas[new Rect(padW, padH, newW, newH)]); float[] data new float[3 * inputSize * inputSize]; // 内存布局从 HWC 转成 CHW并归一化到 [0,1] for (int c 0; c 3; c) { for (int y 0; y inputSize; y) { for (int x 0; x inputSize; x) { Vec3b p canvas.AtVec3b(y, x); float v c 0 ? p.Item0 : c 1 ? p.Item1 : p.Item2; data[c * inputSize * inputSize y * inputSize x] v / 255f; } } } resized.Dispose(); rgb.Dispose(); canvas.Dispose(); return data; }代码里的嵌套循环顺序是从通道循环到行再到列data最终是标准 CHW 布局。这里最容易写错的地方是把x和y的顺序搞反导致推理结果整体转了 90 度另外canvas.AtVec3b是逐像素访问性能一般正式项目里可以用Mat.GetArray或者直接把Mat.Data转成byte*循环能快一个数量级。Scalar(114, 114, 114)是 letterbox 填充色的标准值和训练脚本保持一致。用Rect做 ROI 拷贝时padW和padH分别表示左上角偏移newW/newH是 ROI 的宽高三者关系画一张图就很直观原图被等比缩放后居中放在一张 640x640 的灰底画布上。3.3 创建 InferenceSession 并组装 DenseTensorusing Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime.Tensors; var options new SessionOptions(); options.LogSeverityLevel OrtLoggingLevel.ORT_LOGGING_LEVEL_WARNING; var session new InferenceSession(doclayout_yolo.onnx, options); // 动态读取输入名不要硬编码 images string inputName session.InputMetadata.Keys.First(); int[] inputShape session.InputMetadata[inputName].Dimensions; Console.WriteLine($input: {inputName}, shape: {string.Join(,, inputShape)}); var tensor new DenseTensorfloat(data, new[] { 1, 3, 640, 640 }); var inputs new ListNamedOnnxValue { NamedOnnxValue.CreateFromTensor(inputName, tensor) }; using var results session.Run(inputs); var output results.First().AsTensorfloat(); Console.WriteLine($output shape: {string.Join(,, output.Dimensions)});InferenceSession在构造时就会加载模型并解析图结构如果 onnx 文件路径不对或者 opset 版本不兼容这里直接抛异常不会拖到Run才报错。LogSeverityLevel设置成WARNING是为了排错时能看到算子级别的警告正式发布可以调成ERROR避免刷日志。DenseTensor的构造参数是按行优先排布的扁平数组如果你的data数组是 CHW 顺序这里直通即可。session.InputMetadata返回的Dimensions里静态 shape 是具体数值动态维度会显示 -1这也是前面坚持导出静态 shape 的原因你可以在初始化时断言inputShape[1] 3 inputShape[2] 640 inputShape[3] 640不满足就直接报错避免把错误数据送进模型。3.4 先验证一次推理结果再写后续逻辑第一次跑通时把输出 shape 打印出来然后随便取一个候选框的坐标值看看是不是在 0~1 之间。坐标值如果在 0 到 1 之间说明模型输出是归一化坐标如果是 0 到 640 之间说明输出已经映射到了输入图片尺寸。这两种情况后面解码时处理方式不同不要靠猜打印一次就能确定。常见做法是写一个最小的控制台程序输入一张测试图输出张量 shape 和几个坐标值确认无误后再集成到上位机工程里。4. 输出解码与 NMS把张量还原成文档区域框4.1 先判断输出排布再决定遍历方式假设打印出来的输出是[1, 84, 8400]其中 84 表示 4 个坐标值加 80 个类别分数8400 是候选框总数。解码的直观思路是外层循环 8400 个候选框内层找类别分数的最大值然后用阈值过滤。如果输出是[1, 8400, 84]同样是外层循环候选框但取分数时要写成output[0, i, j]。DocLayout-YOLO 的类别数取决于权重常见的公开模型类别包括title、text、figure、table、header、footer等。下面代码用numClasses常量表示类别数拿到 onnx 后根据output.Dimensions[1] - 4或-5算出来填进去。4.2 解码候选框并做 letterbox 逆变换class Detection { public RectangleF Rect; public float Score; public int Class; } const int numClasses 80; // 按实际模型修改 const float confThres 0.25f; const float iouThres 0.45f; var boxes new ListDetection(); int rows output.Dimensions[1]; // 84 或 85 int cols output.Dimensions[2]; // 8400 for (int i 0; i cols; i) { float obj 1.0f; int offset 4; // 如果输出维度是 5 numClasses说明有 objectness 通道 if (rows 5 numClasses) { obj output[0, 4, i]; offset 5; } int cls 0; float maxScore 0; for (int j offset; j rows; j) { float p output[0, j, i]; if (p maxScore) { maxScore p; cls j - offset; } } float score obj * maxScore; if (score confThres) continue; float cx output[0, 0, i]; float cy output[0, 1, i]; float w output[0, 2, i]; float h output[0, 3, i]; // letterbox 逆变换先把框还原到 640 画布再映射回原图 float x1 (cx - w / 2 - padW) / scale; float y1 (cy - h / 2 - padH) / scale; float x2 (cx w / 2 - padW) / scale; float y2 (cy h / 2 - padH) / scale; boxes.Add(new Detection { Rect new RectangleF(x1, y1, x2 - x1, y2 - y1), Score score, Class cls }); }代码里的padW、padH、scale来自预处理函数。逆变换时要注意如果模型输出坐标是归一化的 0~1 值得先乘 640 再做减法如果输出已经是绝对像素坐标直接减padW/padH再除scale。两种模式在同一个模型上不会混用但不同权重之间经常不一样。obj * maxScore是 YOLO 系列常用的最终置信度计算方式。如果模型本身没有 objectness 通道obj就固定为 1.0只保留类别分数。这里用rows 5 numClasses做判断比硬编码84或85更稳。4.3 手写 NMS 替代外部依赖static float IoU(RectangleF a, RectangleF b) { float x1 Math.Max(a.Left, b.Left); float y1 Math.Max(a.Top, b.Top); float x2 Math.Min(a.Right, b.Right); float y2 Math.Min(a.Bottom, b.Bottom); float inter Math.Max(0, x2 - x1) * Math.Max(0, y2 - y1); float areaA a.Width * a.Height; float areaB b.Width * b.Height; return inter / (areaA areaB - inter); } static ListDetection NMS(ListDetection dets, float iouThres) { dets.Sort((a, b) b.Score.CompareTo(a.Score)); var keep new ListDetection(); while (dets.Count 0) { var top dets[0]; keep.Add(top); dets.RemoveAt(0); var toRemove new ListDetection(); foreach (var d in dets) { if (IoU(top.Rect, d.Rect) iouThres) toRemove.Add(d); } foreach (var d in toRemove) dets.Remove(d); } return keep; }手写 NMS 而不是引第三方库是因为文档版面检测的候选框经过置信度过滤后通常只剩几十个暴力双重循环的复杂度完全可接受。如果你引了 YoloTiny 之类的包它内部可能依赖特定版本的 OnnxRuntime反而会和你项目里的版本打架。toRemove先收集再统一删除是因为在foreach里直接Remove会抛InvalidOperationException。4.4 解码参数表和类别映射参数推荐值调整方向confThres0.25调低到 0.15 会漏出更多小框调高到 0.4 可减少误检iouThres0.45检测表格这类粘连区域时调到 0.5 以上可以减少框合并inputSize640长文档扫描件可试 960但推理耗时增加明显numClasses按模型实际值从 onnx 输出维度动态计算类别 ID 对应的名称建议放在labels.txt里按类名逐行排列这样训练集重训后只替换文本文件不用改代码。常见版面类别按顺序大概是title、plain text、abandon、figure、figure caption、table、table caption、table footnote、isolate formula、formula caption但不同数据集的顺序差异很大千万不要凭记忆写死。5. 落到业务里部署包结构、Session 复用与排错5.1 rar 解开后应该包含什么这类部署包解压后常见做法是保持四个组成部分onnx 模型文件、OnnxRuntime 原生动态库、类别标签文件、你的 C# 可执行程序。动态库默认放在runtimes/win-x64/native/onnxruntime.dll如果项目没有正确复制这个文件运行时会报Failed to load library而且报错信息里不会提示缺哪个 DLL只会在事件查看器里留下一条加载失败记录。文件或目录作用注意事项models/doclayout_yolo.onnx模型权重用相对路径读取runtimes/win-x64/native/onnxruntime.dllOnnxRuntime 原生库版本必须和 NuGet 包一致labels.txt类别名映射每行一个类名顺序固定DocLayoutApp.exe主程序启动时检查模型锁文件读取模型不要用Environment.CurrentDirectory因为通过服务方式启动时工作目录可能是system32。用AppContext.BaseDirectory拼路径才是稳定的做法。string modelPath Path.Combine(AppContext.BaseDirectory, models, doclayout_yolo.onnx);5.2 Session 复用与并发控制InferenceSession创建一次是重量级操作包含模型解析和算子优化绝不能在每页图片处理时都 new 一个。Session 本身是线程安全的多个线程可以同时调用Run但 CPU 推理时并发Run的性能提升有限还可能因为线程争抢导致单页延迟变大。我一般会在服务里加一个信号量把推理过程串行化。private readonly SemaphoreSlim _inferLock new(1, 1); public async TaskListDetection DetectAsync(Mat image) { await _inferLock.WaitAsync(); try { float[] data Preprocess(image, out float scale, out int padW, out int padH); var tensor new DenseTensorfloat(data, new[] { 1, 3, 640, 640 }); var inputs new ListNamedOnnxValue { NamedOnnxValue.CreateFromTensor(inputName, tensor) }; using var results session.Run(inputs); var output results.First().AsTensorfloat(); return DecodeOutput(output, scale, padW, padH); } finally { _inferLock.Release(); } }SemaphoreSlim在这里的作用是让文档流按页排队而不是让几十页扫描件同时挤进 CPU 跑后一种情况内存占用会突然冲高。如果你的实际场景是并发请求很多且部署在 GPU 机器上可以把信号量去掉但输出张量的读取和输入张量的构造仍然要做成局部变量不能复用。5.3 部署机上的常见排错点启动报Failed to load onnxruntime.dll先检查runtimes目录是否完整再看部署机是否装了 VC Redistributable。OnnxRuntime 的 Windows 版本依赖 Visual C 运行库精简系统上经常缺这个。推理报Invalid Feed基本是输入名硬编码导致。用session.InputMetadata.Keys.First()动态获取后大部分情况能直接解决。输出全部是 0 或者坐标异常大概率是预处理时通道顺序错了或者归一化时忘了除以 255。可以用一张纯红色图片做单元测试正确模式下红色通道的均值应该接近 1.0蓝色通道接近 0。5.4 性能验证的小技巧验证部署是否达标不要只看单帧推理时间要把预处理、推理、解码三段分开计时。常见做法是跑 100 张同样大小的页面取 P50 和 P95 两个指标。如果解码耗时占比超过推理耗时的三分之一优先检查是不是在Mat.AtVec3b上花太多时间如果是改成一次性拷出整块byte[]再手动重排。文档版面检测的坐标最后要落到 PDF 页面上时注意把scale换算和 PDF 的 DPI 参数分开维护扫描件实际 DPI 常标 200 而不是 300写死 DPI 会让检测框整体偏移几个像素留一个参数给调用方传值后面调整时不用重新编译。本文还有配套的精品资源点击获取