简介面向文档版面分析开发者的C#部署资源包基于OnnxRuntime运行DocLayout-YOLO目标检测模型可处理多样性文档的实时鲁棒版面解析适合熟悉C#与YOLO生态的进阶开发者快速落地。压缩包共325个文件、463.21MB主要包含onnx模型文件、C#工程源码cs/csproj/sln、OnnxRuntime原生依赖dll/so/dylib/aar及配套配置文件xml/json/config另有模型说明文档与示例图片便于对照验证。已有329人学习下载。包内完整呈现工程化部署所需结构涉及DocSynth-300K合成数据集与全局到局部自适应感知模块等关键设计有助于理解文档元素尺度变化下的检测优化思路可作为二次开发、模型替换或移动端集成的参考基线。1. C# OnnxRuntime部署DocLayout-YOLO版面分析直接进上位机文档票据识别这类C#上位机场景常见做法是把图片发给Python服务等它返回文字和坐标。多一层服务就多一层部署成本网络抖动时整个流程就卡住。C# OnnxRuntime部署DocLayout-YOLO的思路是把版面分析模型转成ONNX放进C#进程里直接推理扫描件进来后先由模型分出标题、正文、表格、图片区域再决定后面走OCR还是走归档。这套方案尤其适合三类人在做WPF文档管理工具的批量处理PDF扫描件、想先定位版面再OCR的被Python环境依赖和版本问题反复折腾、想用NuGet一条龙解决的上位机开发者。下面从拿到模型开始按我实际调试的顺序写最后把最容易翻车的地方单独列出来。2. 把DocLayout-YOLO变成C#能吃的ONNX模型结构、导出与运行时选择解压压缩包之后先别急着写界面第一步永远是确认你手里的模型是什么格式。如果里面直接是pt权重需要先在Python环境里导出成onnx如果已经是onnx文件那就先写一小段C#代码把输入输出张量的形状打印出来确认它符合预期再继续。这一步能省掉后面大量的排查时间。我见过不少人拿到模型就按YOLOv5的老教程抄后处理跑出来的框全是乱的最后发现是输出张量排列方式压根不一样。DocLayout-YOLO的导出版本在输出布局上确实有几种常见差异提前打印出来比对着报错猜半天靠谱得多。2.1 打印模型输入输出先搞清楚张量形状再写代码新建一个控制台工程引上Microsoft.ML.OnnxRuntime然后跑下面这段代码using Microsoft.ML.OnnxRuntime; var session new InferenceSession(doclayout_yolo.onnx); foreach (var item in session.InputMetadata) { Console.WriteLine($input: {item.Key}, type{item.Value.ElementType}, dims{string.Join(,, item.Value.Dimensions)}); } foreach (var item in session.OutputMetadata) { Console.WriteLine($output: {item.Key}, type{item.Value.ElementType}, dims{string.Join(,, item.Value.Dimensions)}); }这段代码做的事只有一个把模型输入输出元数据打印到控制台。InputMetadata和OutputMetadata是InferenceSession自带的字典键是张量名值是元素类型和维度信息。维度里的0表示动态维度比如1, 3, 1024, 1024是固定尺寸而1, 3, 0, 0说明模型允许动态宽高。正常来说输入名是images输入类型是Tensorfloat形状是1, 3, H, W。输出维度要重点看排列如果看到1, 16, 13440这种说明输出是1 x (5 类别数) x 锚点数的布局如果看到1, 13440, 16则是1 x 锚点数 x (5 类别数)。类别数在13到16之间都很常见取决于训练集和导出的细节。这个差异直接决定后处理怎么遍历先记住第4章会给出兼容两种布局的解析代码。2.2 C#运行时选择为什么CPU版包是默认答案C#侧能用的OnnxRuntime NuGet包就两个Microsoft.ML.OnnxRuntime和Microsoft.ML.OnnxRuntime.Gpu。前者只带CPU执行器后者带CUDA执行器但要求本机装好的CUDA和cuDNN版本与onnxruntime严格对应。GPU版本对不上的最常见后果是启动时抛DllNotFoundException或者干脆在AppendExecutionProvider_CUDA那一步报错。我的建议是WPF上位机、批量文档处理、内部工具这类场景一律先用CPU包跑通。OnnxRuntime在CPU上的优化已经很成熟单页A4扫描件在1024分辨率下通常能跑到两三百毫秒对绝大多数版面分析需求是够用的。等到确认并发量大、单页耗时超过一秒再考虑换GPU包那时候也已经有完整的CPU基线可以做对比。另外注意NuGet包会自动带对应平台的native动态库不需要手动把onnxruntime.dll拷到输出目录。手动拷贝dll反而容易和NuGet包版本不一致出现莫名其妙的入口点找不到错误。2.3 工程目录组织模型别和代码搅在一起这类压缩包解压后常见布局是模型文件加一个示例工程。我的习惯是把模型独立放一层目录代码工程放另一层模型文件在csproj里设置成CopyToOutputDirectory这样输出目录干净排查问题也方便。参考结构DocLayout-YOLO/ ├─ models/ │ └─ doclayout_yolo.onnx └─ src/ └─ DocLayoutDemo/ ├─ DocLayoutDemo.csproj └─ Program.cs模型路径不要写死绝对路径用AppContext.BaseDirectory拼相对路径。部署到别的机器时只要保持models目录相对程序的位置不变就不会出现路径问题。中文路径在旧版本OnnxRuntime里偶尔会加载失败这个放在第5章单独讲。3. 预处理与推理主链路Letterbox、通道顺序与Session.Run模型确认无误之后进入主链路。这一个章节是出错率最高的地方尤其是通道顺序和坐标还原很多人卡在这里。我按顺序把每一段代码和参数说明写清楚。3.1 Letterbox缩放为什么直接Resize会让表格变形直接把原图Resize到模型输入尺寸比如把1000x1400的扫描件压成1024x1024长宽比变了标题会横向变形表格线也会弯掉。版面分析模型训练时用的是Letterbox加灰边填充推理时也必须走同样的流程否则检测框位置系统性偏移。我这里用OpenCvSharp做图像处理主要是因为C#上位机场景里它已经是很常见的依赖。核心代码如下using OpenCvSharp; const int TargetSize 1024; float scale; int newW, newH, padX, padY; using var src new Mat(imagePath, ImreadModes.Color); scale Math.Min((float)TargetSize / src.Width, (float)TargetSize / src.Height); newW (int)Math.Round(src.Width * scale); newH (int)Math.Round(src.Height * scale); using var resized new Mat(); Cv2.Resize(src, resized, new Size(newW, newH), 0, 0, InterpolationFlags.Linear); padX (TargetSize - newW) / 2; padY (TargetSize - newH) / 2; using var canvas new Mat(new Size(TargetSize, TargetSize), MatType.CV_8UC3, new Scalar(114, 114, 114)); resized.CopyTo(canvas[new Rect(padX, padY, newW, newH)]);scale取的是两个方向缩放比例中较小的那个保证图片完整放进去不会裁掉边缘内容。padX和padY是等比缩放后两边补灰边的像素数这两个值必须保留下来第4章坐标还原全靠它们。灰边值用114这是YOLO系列训练时通用的填充值用0或者255都会让边缘区域产生额外的响应。3.2 通道顺序与归一化BGR换RGB是第一个坑OpenCvSharp读进来的图片是BGR通道顺序而DocLayout-YOLO训练时通常使用RGB。如果直接喂给模型检测结果不会完全没有输出但框的位置和置信度都会变得奇怪尤其是彩色区域的检测会明显退化。这一步必须显式转换。using var rgb new Mat(); Cv2.CvtColor(canvas, rgb, ColorConversionCodes.BGR2RGB); var inputData new float[1 * 3 * TargetSize * TargetSize]; for (int y 0; y TargetSize; y) { for (int x 0; x TargetSize; x) { var px rgb.AtVec3b(y, x); inputData[0 * TargetSize * TargetSize y * TargetSize x] px.Item0 / 255f; inputData[1 * TargetSize * TargetSize y * TargetSize x] px.Item1 / 255f; inputData[2 * TargetSize * TargetSize y * TargetSize x] px.Item2 / 255f; } }这段代码做了两件事把BGR转成RGB再把HWC数据展开成CHW连续内存。inputData的索引规则是c * TargetSize * TargetSize y * TargetSize x也就是先按通道排再按行排最后是列。归一化只做了除以255没有减均值因为YOLO系模型一般只做归一化加了均值反而不对。注意AtVec3b在循环里逐像素访问性能一般如果处理大批量文档建议改用Mat.Data配合Marshal.Copy取整块内存速度能快一个量级。小批量处理时逐像素可读性优先先把流程跑通。3.3 构造Tensor并推理固定形状下直接new DenseTensor数据准备好之后用DenseTensor包装成模型期望的形状然后走Run。这里有个很容易忽略的点NamedOnnxValue.CreateFromTensor第一个参数必须和模型的输入名一致也就是2.1节打印出来的那个名字通常是images。using Microsoft.ML.OnnxRuntime.Tensors; var tensor new DenseTensorfloat(inputData, new[] { 1, 3, TargetSize, TargetSize }); var inputs new ListNamedOnnxValue { NamedOnnxValue.CreateFromTensor(images, tensor) }; using var results session.Run(inputs); var output results.First().AsTensorfloat(); Console.WriteLine($output dims: {string.Join(,, output.Dimensions)});Run返回的是IDisposableReadOnlyCollectionDisposableNamedOnnxValue用完要释放。results.First()取的是模型第一个输出有时候模型会带多个输出比如一个走检测头一个走分类头但DocLayout-YOLO部署版一般只保留检测输出这里先按单个输出来处理。如果之前打印输入维度时看到0也就是动态维度那么输入张量可以传真实图片尺寸比如new[] { 1, 3, realH, realW }。但固定尺寸模型推理效率更高很多导出工具会把尺寸固定成1024或1280这种情况下就必须先用Letterbox把图统一到目标尺寸。3.4 推理线程设置第一次跑通别急着上多线程OnnxRuntime默认会尝试用满所有CPU核心但在桌面端和上位机上不一定友好。推理线程数不是越大越好线程开太多反而会因为内存带宽和缓存竞争拖慢速度。var sessionOptions new SessionOptions(); sessionOptions.SetSessionExecutionMode(ExecutionMode.ORT_SEQUENTIAL); sessionOptions.SetSessionIntraOpNumThreads(Math.Max(2, Environment.ProcessorCount / 2)); var session new InferenceSession(doclayout_yolo.onnx, sessionOptions);ORT_SEQUENTIAL表示算子按顺序逐个执行ORT_PARALLEL会让模型内部并行执行显存和内存占用更高。对版面分析这种单张推理场景ORT_SEQUENTIAL通常更快。SetSessionIntraOpNumThreads控制每个算子内部使用的线程数设成核心数的一半比较稳妥。如果目标机器是四核八线程的工业主机可以先固定设4再往上加看耗时变化。4. 输出解析与后处理从原始张量到版面矩形推理拿到的是一个大浮点数组不是现成的矩形框。把它解析成标题、表格、图片这些区域是整条链路里最需要耐心的部分。下面给出兼容不同输出布局的解析方式。4.1 判断输出布局先看数字分布再写解析循环2.1节打印出来的输出形状现在派上用场。如果形状是1, C, N比如1, 16, 13440说明每个锚点的数据是按列排列的如果形状是1, N, C比如1, 13440, 16说明按行排列。解析代码要兼容这两种情况用维度大小做判断即可。var dims output.Dimensions.ToArray(); bool transposed dims[1] dims[2]; // true表示 1, N, Cfalse表示 1, C, N int count transposed ? dims[1] : dims[2]; int stride transposed ? dims[2] : dims[1]; int classCount stride - 5; // 如果模型输出了objectness这里要改成 stride - 5 - 1 float scoreThreshold 0.35f; var boxes new Listfloat[](); // x1, y1, x2, y2, cls, score for (int i 0; i count; i) { float cx, cy, w, h; if (transposed) { cx output[0, i, 0]; cy output[0, i, 1]; w output[0, i, 2]; h output[0, i, 3]; } else { cx output[0, 0, i]; cy output[0, 1, i]; w output[0, 2, i]; h output[0, 3, i]; } float maxScore 0f; int bestCls -1; for (int c 0; c classCount; c) { float score transposed ? output[0, i, 5 c] : output[0, 5 c, i]; if (score maxScore) { maxScore score; bestCls c; } } if (bestCls 0 maxScore scoreThreshold) { boxes.Add(new float[] { cx - w / 2f, cy - h / 2f, cx w / 2f, cy h / 2f, bestCls, maxScore }); } }classCount由输出维度推出不需要硬编码。如果模型带了objectness维度类别起点是6而不是5判断方式是看classCount算出来的值是否明显小于正常类别数。scoreThreshold先用0.35起步后续根据漏检和误检情况在0.25到0.5之间调。版面分析场景漏掉一个表格比多一个噪声框代价更大阈值可以适当放低。4.2 非极大值抑制文档框重叠比想象的多版面框之间经常重叠比如标题框压着正文框表格跨栏时和文本区域重叠。直接过滤置信度还不够必须做NMS。文档场景的框大多是扁长矩形IoU阈值不要设太死0.45是一个比较稳的起点。static Listfloat[] Nms(Listfloat[] boxes, float iouThreshold) { var filtered new Listfloat[](); var ordered boxes.OrderByDescending(b b[5]).ToList(); while (ordered.Count 0) { var best ordered[0]; filtered.Add(best); ordered.RemoveAt(0); ordered.RemoveAll(b { float x1 Math.Max(best[0], b[0]); float y1 Math.Max(best[1], b[1]); float x2 Math.Min(best[2], b[2]); float y2 Math.Min(best[3], b[3]); float inter Math.Max(0, x2 - x1) * Math.Max(0, y2 - y1); float union (best[2] - best[0]) * (best[3] - best[1]) (b[2] - b[0]) * (b[3] - b[1]) - inter; return union 0 inter / union iouThreshold; }); } return filtered; }这段实现是经典贪心NMS每次取置信度最高的框去掉所有和它IoU超过阈值的框。OrderByDescending保证优先进去看分数高的候选。如果做的是同一类别内部的NMS可以把类别维度加进比较条件避免标题框和正文框互相误删。文档版面分析里不同类别的框重叠是合理的跨类别剔除反而会把表格和它的caption砍掉。NMS之后建议按类别分组输出因为调用方往往只需要表格区域或者只需要标题区域一次性把所有框返回去反而增加业务代码的判断逻辑。4.3 坐标还原到原图Letterbox的pad和scale要用对模型输出的坐标全是在1024x1024的Letterbox画布上算的要映射回原始扫描件必须把pad和scale反向处理。很多人的检测框位置整体偏左上或右下就是这一步写反了。foreach (var box in nmsBoxes) { float x1 (box[0] - padX) / scale; float y1 (box[1] - padY) / scale; float x2 (box[2] - padX) / scale; float y2 (box[3] - padY) / scale; x1 Math.Max(0, x1); y1 Math.Max(0, y1); x2 Math.Min(src.Width, x2); y2 Math.Min(src.Height, y2); }坐标从模型空间回到原图空间公式就一个减去pad再除以scale。这里scale和padX、padY必须来自同一张图的Letterbox参数跨图复用必然出错。最后加一层Math.Max和Math.Min把坐标裁剪到图片边界内防止模型在灰边区域输出越界框。4.4 类别索引映射模型输出的是数字业务用的是语义DocLayout-YOLO的输出是类别索引业务代码最终拿到的应该是title、plain text、table、figure这样的语义标签。不同数据集训练的模型类别顺序不一样必须以模型自带的classes文件为准。string[] classes LoadClasses(models/classes.txt); foreach (var box in nmsBoxes) { int clsIdx (int)box[4]; string label classes[clsIdx]; Console.WriteLine(${label}: ({box[0]:F1}, {box[1]:F1}) - ({box[2]:F1}, {box[3]:F1}), score{box[5]:F2}); }LoadClasses就是按约定俗成的规定逐行读取文本文件每行一个类别名顺序必须和训练脚本里一致。如果模型文件里没有附带类别文件可以通过模型输出维度反推类别总数但具体哪个索引对应哪个类别只能靠实测比对拿几张已知版面的文档验证一遍再固化到配置里。5. 部署中的常见问题与排查五个最容易翻车的细节这一章是把我在多个项目里反复踩过的坑集中列出来。每一条都是真实遇到过、并且排查起来相当花时间的按现象、原因、解决方案的格式整理。5.1 现象检测框全是竖直长条或横条置信度还挺高原因大概率是通道顺序反了。OpenCvSharp读图默认BGRDocLayout-YOLO训练用RGB不转换直接推理会让模型在彩色区域产生大量错误响应长条框通常出现在有彩色插图的段落附近。解决方式是在3.2节的位置加Cv2.CvtColor(canvas, rgb, ColorConversionCodes.BGR2RGB)问题会立刻消失。如果转换了还是乱的检查归一化是不是除了除以255之外还减了均值多减均值同样会让分布偏移。5.2 现象CPU推理一页要三秒以上完全没法上线不到万不得已先不要换GPU。先确认模型输入尺寸是不是被固定成了不合理的值比如1280甚至1536对普通A4扫描件1024完全足够。然后把3.4节的线程设置加上SetSessionIntraOpNumThreads设为核心数的一半以上实测经常能从三秒降到一秒以内。如果本机是Intel CPU还可以尝试带OpenVINO执行器的OnnxRuntime包这是CPU上提升最明显的路径。需要注意不是官方默认NuGet包需要下载对应的运行时包并且推理线程数要重新调一遍。5.3 现象输出张量维度和教程代码对不上一跑就越界OnnxRuntime不会报越界output[0, i, c]取到空值时会静默返回0结果就是框的数量少一半或者全是位置错误。原因就是输出布局不一样1, C, N和1, N, C两类模型都存在。解决方式是回到第2章那段打印代码把输出维度完整打出来然后用4.1节的兼容代码判断transposed。不要试图用output[0]的线性索引数去找规律那个数字越看越乱直接按维度走。5.4 现象模型路径带中文或空格时加载失败旧版本OnnxRuntime的InferenceSession(string)在Windows上遇到中文路径有时会抛文件找不到的异常但文件明明在。原因是native层文件打开走的是窄字符路径和C#的Unicode路径不一致。解决方式是绕开字符串路径先读成字节数组再加载using var fs File.OpenRead(modelPath); using var ms new MemoryStream(); fs.CopyTo(ms); var session new InferenceSession(ms.ToArray(), sessionOptions);注意从字节数组加载模型时OnnxRuntime默认不会对模型做文件级的优化缓存第一次推理会略慢但换来路径兼容性值得。如果还很在意启动速度可以配合模型优化后再保存把优化后的模型单独存一份。5.5 现象WPF界面卡死点一下窗口无响应session.Run是同步阻塞的在UI线程里跑一张几百毫秒的推理界面直接假死。批量处理时更严重队列稍长就变成持续无响应。解决方式是用Task.Run把推理丢到后台线程结果通过Dispatcher回传UI。预处理和后处理也尽量放在同一个后台任务里UI线程只负责显示结果。private async Task ProcessImageAsync(string path) { var result await Task.Run(() { using var src new Mat(path, ImreadModes.Color); // 预处理、推理、后处理全部放在这里 return boxes; }); // 回到UI线程显示结果 DrawBoxes(result); }如果做的是批量文档处理别在UI线程里写循环用Channel或者BlockingCollection搭生产消费模型Task.Run跑消费端逐张出结果。C#多线程的线程池调度在短任务上开销不小但版面分析单张推理至少几十毫秒异步收益明显大于调度开销。6. 进阶验证用Benchmark脚本量化性能再决定要不要上GPU性能问题不要靠感觉写个简单的基准脚本拿真实文档跑一遍再拍板。选20张具有代表性的页面覆盖纯文本页、带表格页、带插图页统计推理耗时分布。var stopwatch System.Diagnostics.Stopwatch.StartNew(); using var results session.Run(inputs); var output results.First().AsTensorfloat(); stopwatch.Stop(); Console.WriteLine($推理耗时: {stopwatch.ElapsedMilliseconds} ms);建议统计P50和P95耗时P50代表典型体验P95代表最坏情况。如果P95超过一秒再决定要不要上GPU或换Small模型。DocLayout-YOLO本身有不同规模的变体小模型在CPU上的提升比换执行器更直接。GPU路径不要只看显存够不够。Microsoft.ML.OnnxRuntime.Gpu的CUDA版本对应关系很严CUDA、cuDNN、onnxruntime三者版本必须匹配错一个都跑不起来。真上了GPU再把模型转成FP16显存占用和推理速度都会有明显改善但CPU上FP16没有收益处理器的FP32单元才是主力。我自己的习惯是每台新机器部署完成之后留一份基准测试结果存档。下次换模型、换版本、换机器先跑同一份测试集对比比任何口头描述都直观。当初第一次做这个方案时我也踩过通道顺序的坑白折腾了一整个下午。现在每套工程里都会在预处理入口放一个开关方便调试时快速切换BGR和RGB再也没在这种细节上浪费过时间。希望帮到你。本文还有配套的精品资源点击获取