C# OnnxRuntime部署DocLayout-YOLO:文档版面分析实战全流程
2026/9/23 17:18:46 网站建设 项目流程

简介:面向C#开发者的DocLayout-YOLO部署资源包,基于OnnxRuntime实现文档版面分析模型的本地推理,解决在.NET环境下快速集成版面检测能力的难题。包内包含完整的Visual Studio解决方案,涵盖C#源码、工程文件、ONNX模型文件以及配套依赖库,适合有C#基础并希望复用文档智能处理能力的开发者。压缩包共325个文件,总体积463MB。主要文件类型包括dll运行库、cs源码与sln/csproj工程文件、onnx模型、xml/json/config配置文件、jpg/png示例图像,以及md/txt说明文档,结构清晰便于按需取用。已有329人学习,适合需要快速上手DocLayout-YOLO的C#开发者。通过该资源,读者可获取从模型加载、预处理到后处理输出的完整调用流程,了解如何利用全局到局部感知模块适配不同版面元素,并基于示例图片验证检测效果。对于正在做文档解析、版面还原或OCR预处理的项目,可直接复用其中脚本与配置,显著降低调研成本。

1. C# OnnxRuntime部署DocLayout-YOLO:为什么我把版面分析从Python服务搬进了C#客户端

C# OnnxRuntime部署DocLayout-YOLO,这个组合最近帮我解决了一个很现实的文档结构化需求。DocLayout-YOLO是基于YOLOv10的文档版面分析模型,预训练阶段用Mesh-candidate BestFit把文档合成当二维装箱问题来处理,生成DocSynth-300K合成数据集,再配合全局到局部自适应感知模块,让模型在尺度差异很大的文档元素上也能输出稳定检测框。对做C#上位机或者WPF桌面工具的人来说,最舒服的一点是不用再架一个Python推理服务,直接把ONNX模型交给OnnxRuntime,在本地就能跑出标题、正文、表格、图表这些区域。这篇笔记不聊太虚的算法理论,就从模型输入输出开始,一直写到C#代码实现、后处理、避坑和业务接入。

2. 先看懂模型再写代码:DocLayout-YOLO的ONNX输入输出与导出检查

2.1 模型结构:YOLOv10的NMS-Free检测头和它带来的两个结果

DocLayout-YOLO的主干是YOLOv10。YOLOv10和YOLOv8最大的区别是训练时用one-to-one匹配,推理时直接输出经过筛选的候选框,不再依赖传统NMS后处理。反映到ONNX模型上,输出张量通常是1x300x6,这个结构比YOLOv8那种1x84x8400紧凑得多,但也更容易让人在C#端写错解析逻辑。

我第一次拿到这个onnx文件时,习惯性地按YOLOv8的格式去解析输出,结果数组越界。后来用Netron打开才确认输出节点是output0,形状1x300x6,最后一维的6个通道依次是中心点x、中心点y、宽、高、置信度、类别ID。这个信息必须在一开始就确认清楚,否则后面所有代码都是在猜。

DocLayout-YOLO的文档预训练阶段用的是DocSynth-300K,里面把文档版面拆成标题、段落、图表等区块,再用Mesh-candidate BestFit重新拼装成合成页面。这样训练出来的模型对多栏排版、图表混排、扫描倾斜这类真实场景更鲁棒。全局到局部自适应感知模块主要解决尺度问题,文档里一个标题和一张大图的像素尺度可能差几十倍,这个模块让模型在不同感受野之间做全局到局部的感知融合。

这些算法细节在部署时不需要完全复现,但会影响两个工程参数:一是模型输入分辨率建议用640或1280,二是小目标的召回率比原版YOLOv10好。代价是模型体积和推理耗时稍微增加,在纯CPU机器上跑一张A4扫描件,耗时通常在一两百毫秒到两秒之间,具体取决于输入分辨率和机器性能。

2.2 导出ONNX:用官方导出脚本检查opset和动态轴

拿到资源包之后,第一件事不是写C#,而是把ONNX模型的信息确认清楚。常见做法是用YOLOv10官方仓库的export.py从官方权重导出,DocLayout-YOLO仓库里也提供了对应的导出方式。导出命令大致是这样:

yolo export model=doclayout_yolo.pt format=onnx opset=12 dynamic=False imgsz=640

这里的dynamic=False表示输入尺寸固定为1x3x640x640。如果文档里有大量小字号文字或表格caption,可以把imgsz改成1280,召回率会有肉眼可见的提升,但CPU推理时间会翻三四倍。opset建议选12到16之间的稳定版本,OnnxRuntime新版本对旧opset兼容性很好,但老版本OnnxRuntime跑高版本opset会直接报unsupported operator。

导出完成后用Netron打开onnx文件,重点看三处:输入节点的名称、输入张量形状、输出节点的名称和形状。YOLOv10的ONNX输出常见是1x300x6,如果你的文件是1x8400x6或者1x25200x6,说明模型没有走完整的NMS-Free导出流程,后处理里必须自己加置信度过滤和NMS,不能直接按300个候选框去读。

提示:不要在没确认输出形状前就套代码。我用YOLOv8的后处理逻辑去读YOLOv10的输出,前三次推理全部报数组越界,白白折腾了小半天。

2.3 用Netron确认尺寸和类别数

打开ONNX后,找到最后的输出节点。输出维度最后一维如果是6,那6个通道就是标准YOLOv10 NMS-Free结构。如果最后一维是4加上类别数再加置信度,比如4+12+1=17,那就是常规YOLO头输出,后处理必须走NMS,不能直接信任输出里的300个框。

类别顺序也很关键。如果资源包里有labels文件,读出来应该是一行一个类别名。DocLayout-YOLO在文档版面任务里常见类别有title、plain text、figure、table、figure caption、table caption、header、footer这些,但不同训练权重的类别顺序不一定一样。C#端最好把整个labels文件读进数组,不要硬编码,否则画框时很容易错一位,标题标成正文,表格标成图,排查半天找不到原因。

我一般会写一个最小检查片段,把输入输出元数据直接打印出来:

using Microsoft.ML.OnnxRuntime; using var session = new InferenceSession("doclayout_yolo.onnx"); foreach (var meta in session.InputMetadata) Console.WriteLine($"Input {meta.Key}: {string.Join(",", meta.Value.Dimensions)}"); foreach (var meta in session.OutputMetadata) Console.WriteLine($"Output {meta.Key}: {string.Join(",", meta.Value.Dimensions)}");

这段代码比Netron更直接,因为OnnxRuntime解析出来的Dimensions就是C#代码里要用的实际维度。如果Dimensions里有-1这种动态轴,要在SessionOptions里用FreeDimensionOverride指定具体值,或者在导出时就固定尺寸,后者省事得多。

2.4 输入输出速查表

以常见导出配置为例,关键参数整理成一张表,后面C#代码注释里也会用到:

项目常见取值说明
输入名称images以Netron或InputMetadata为准,不硬编码
输入形状1x3x640x640CHW格式,RGB三通道
像素归一化除以255YOLO系列惯例,不做ImageNet标准化
输出名称output0以Netron或OutputMetadata为准
输出形状1x300x6YOLOv10 NMS-Free常见输出
输出通道含义cx, cy, w, h, score, class_id坐标相对640x640输入图
类别视权重而定读labels文件,不硬编码

这张表是面向实际部署的,不是模型理论分析。后面所有预处理和后处理代码,都以这行参数为准。如果实际模型输出不是1x300x6,先把4.1节那个循环的行数改掉,再确认是否需要额外来一道NMS。许多C#推理翻车,本质上都是输入输出层与C#端预期不一致,和OnnxRuntime本身没关系。

3. C#工程搭建与OnnxRuntime推理:从NuGet到第一行预测代码

3.1 创建项目并引入OnnxRuntime包

C#端我最常用的落地形态是控制台原型加WPF界面,如果你要做的是c#上位机那种桌面工具,WPF比WinForms更适合做版面标注和图片预览。建议先建一个.NET 8控制台项目验证推理流程,跑通后再把代码搬进WPF的ViewModel层。

新建项目时目标平台一定要选x64。OnnxRuntime的原生native库对x64支持最完整,AnyCPU在部分Windows机器上会因为加载不到合适架构的dll而直接失败。用NuGet安装官方包:

dotnet add package Microsoft.ML.OnnxRuntime

旧机器上跑可以用1.16左右的版本,新机器直接装最新稳定版。重点检查发布目录下有没有runtimes文件夹,里面是各个平台的native dll。发布时选Self-contained或者把runtimes一起带上,否则客户机器上没有对应运行时,会报DllNotFoundException。

3.2 图像读取与Letterbox预处理

直接Resize到640x640会让非正方形文档产生横向或纵向拉伸,检测框映射回原图后整体偏移,这个问题在文档扫描件上特别明显。正确做法是Letterbox,把原图按比例缩放到640一边,另一边用灰色填充。

static (Bitmap Canvas, float Scale, int PadX, int PadY) Letterbox(Bitmap src, int inputSize = 640) { float scale = Math.Min((float)inputSize / src.Width, (float)inputSize / src.Height); int newW = (int)(src.Width * scale); int newH = (int)(src.Height * scale); int padX = (inputSize - newW) / 2; int padY = (inputSize - newH) / 2; var canvas = new Bitmap(inputSize, inputSize); using (var g = Graphics.FromImage(canvas)) { g.Clear(Color.Gray); g.DrawImage(src, padX, padY, newW, newH); } return (canvas, scale, padX, padY); }

这个方法的返回值里,Scale、PadX、PadY必须在后处理时传回去。很多第一次做C# OnnxRuntime部署的同事就是忽略了这三个值,导致画出来的框偏大或偏小。InputSize要和导出ONNX时的imgsz一致,模型导成640就用640,不要到代码里随便改。

如果输入的是超高分辨率扫描图,比如PDF转出来的4000像素长图,我一般会先判断长边是否超过2500,超过就先等比缩小到2500再做Letterbox,否则一次性压到640会丢失小字号的检测目标。

3.3 构建输入张量并执行推理

构建张量时需要注意像素格式和通道顺序。YOLO系列训练时用RGB顺序,而Bitmap底层通常是BGR,LockBits读出来之后要交换通道。下面这段是完整的张量转换:

static DenseTensor<float> ToTensor(Bitmap bmp, int inputSize = 640) { var tensor = new DenseTensor<float>(new[] { 1, 3, inputSize, inputSize }); var rect = new Rectangle(0, 0, inputSize, inputSize); var data = bmp.LockBits(rect, ImageLockMode.ReadOnly, PixelFormat.Format24bppRgb); int stride = data.Stride; var bytes = new byte[stride * inputSize]; System.Runtime.InteropServices.Marshal.Copy(data.Scan0, bytes, 0, bytes.Length); bmp.UnlockBits(data); for (int y = 0; y < inputSize; y++) { int row = y * stride; for (int x = 0; x < inputSize; x++) { int idx = row + x * 3; tensor[0, 0, y, x] = bytes[idx + 2] / 255f; tensor[0, 1, y, x] = bytes[idx + 1] / 255f; tensor[0, 2, y, x] = bytes[idx + 0] / 255f; } } return tensor; }

这里用LockBits而不是GetPixel,因为GetPixel在循环里性能很差,100万像素要几十毫秒。Stride可能比Width乘3大,原因是内存对齐,所以每行要用row加stride偏移,不能直接用y乘Width乘3。像素归一化只做除以255,不要额外减均值,YOLO训练时没有做ImageNet标准化,减均值后置信度会塌缩到接近0。

推理调用节点名称用第一步确认的images:

using var results = session.Run(new List<NamedOnnxValue> { NamedOnnxValue.CreateFromTensor("images", tensor) }); var output = results.First(r => r.Name == "output0").AsTensor<float>().ToArray();

如果输出节点名不想硬编码,可以用session.OutputMetadata的key来匹配。强行写错名字会在Run时抛异常,提示找不到输入或输出。Output是一个长度为1800的一维数组,怎么解析成检测框,放在下一章讲。

3.4 Session生命周期与线程参数

InferenceSession承载模型权重和优化后的计算图,创建时要完成算子融合和内存规划,CPU上可能要几百毫秒。正确姿势是把它当成单例,在程序启动时创建一次,后续所有推理复用同一个Session。

var opts = new SessionOptions(); opts.OptimizationLevel = GraphOptimizationLevel.ORT_ENABLE_ALL; opts.IntraOpNumThreads = Math.Max(2, Environment.ProcessorCount / 2); opts.AppendExecutionProvider(new DmlExecutionProvider(0)); using var session = new InferenceSession("doclayout_yolo.onnx", opts);

IntraOpNumThreads默认会占满所有核,但在文档图片推理这种单请求场景下,线程数太多反而因为线程切换变慢。我一般给模型留一半核,另一半留给UI和业务线程。如果目标机器是NVIDIA显卡,可以用Microsoft.ML.OnnxRuntime.Gpu包把ExecutionProvider换成CUDA;如果是ARM板子或集成显卡,DmlExecutionProvider或者直接用CPU更省心。之前在rk3588部署yolov8时,用的就是ARM版OnnxRuntime,Session设置逻辑完全一样,只是包版本要选对应架构。

4. 后处理与标注渲染:置信度过滤、NMS和类别映射

4.1 从1x300x6解析检测框

OnnxRuntime输出的float数组是一维的,长度1800。按行优先排列,每行6个元素:中心点x、中心点y、宽、高、置信度、类别ID。第一步先把低置信度的候选去掉:

var dets = new List<Detection>(); int rows = output.Length / 6; for (int i = 0; i < rows; i++) { float score = output[i * 6 + 4]; if (score < 0.5f) continue; float cx = output[i * 6 + 0]; float cy = output[i * 6 + 1]; float w = output[i * 6 + 2]; float h = output[i * 6 + 3]; int cls = (int)output[i * 6 + 5]; dets.Add(new Detection { X = cx - w / 2f, Y = cy - h / 2f, Width = w, Height = h, Score = score, ClassId = cls }); }

Detection类的X、Y、Width、Height这里存的是左上角坐标和宽高,方便后面画框。在文档版面分析场景下,0.5的置信度阈值对清晰扫描件够用。但手机拍的带阴影文档,表格检测置信度会被压到0.3附近,建议这时把阈值降到0.3。YOLOv10的one-to-one输出已经做过稀疏化,300个候选中多数是低分背景,过滤后通常只剩10到30个真实版面区域。

4.2 用C#写一个简洁NMS

虽然YOLOv10号称NMS-Free,但实际导出ONNX后,同一张表格被重复检测的情况偶尔还是会出现,尤其是表格和caption紧挨着的时候。我再加一道NMS求稳,200个框的运算成本可以忽略。

static List<Detection> Nms(List<Detection> dets, float iouThreshold = 0.45f) { var result = new List<Detection>(); foreach (var d in dets.OrderByDescending(d => d.Score)) { bool keep = true; foreach (var kept in result) { if (IoU(d, kept) > iouThreshold) { keep = false; break; } } if (keep) result.Add(d); } return result; } static float IoU(Detection a, Detection b) { float x1 = Math.Max(a.X, b.X); float y1 = Math.Max(a.Y, b.Y); float x2 = Math.Min(a.X + a.Width, b.X + b.Width); float y2 = Math.Min(a.Y + a.Height, b.Y + b.Height); 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 + 1e-6f); }

这里的IoU实现是标准的四边相交,加了1e-6f防止除零。文档版面中标题和正文天然紧挨着,IoU阈值不要低于0.4,否则相邻的标题和段落会被错误合并。反过来,如果检测目标是单元格粒度,同一区域可能有表格和表格caption两个类别的框重叠,这时可以在类别维度单独做一次NMS,只对同类框做抑制。

4.3 坐标映射回原图与绘制

模型输出坐标是在640x640输入图上的绝对像素值,要映射回原图,必须把Letterbox记录的Scale、PadX、PadY反过来算:

foreach (var d in nmsDets) { float origX = (d.X - padX) / scale; float origY = (d.Y - padY) / scale; float origW = d.Width / scale; float origH = d.Height / scale; using var pen = new Pen(Color.Red, 3f); g.DrawRectangle(pen, origX, origY, origW, origH); }

这里有一个容易忽略的细节:Letterbox在奇数像素分配时会出现0.5像素偏差,映射回原图后可能偏1到2像素。对检测框来说没问题,但如果要把表格区域裁出来做OCR,建议把区域外扩2%到5%,避免把表头或外层边框裁掉。

保存标注图是验证部署正确性最直观的方式:

static void SaveAnnotated(string imagePath, List<Detection> dets, string[] classNames) { using var bmp = new Bitmap(imagePath); using var g = Graphics.FromImage(bmp); foreach (var d in dets) { using var pen = new Pen(Color.Red, 3f); using var font = new Font("Arial", 20f); g.DrawRectangle(pen, d.X, d.Y, d.Width, d.Height); g.DrawString($"{classNames[d.ClassId]} {d.Score:0.00}", font, Brushes.Red, d.X, d.Y - 24f); } bmp.Save(Path.ChangeExtension(imagePath, ".annotated.png")); }

这里假设d.X和d.Y已经是原图坐标。我用这个函数把所有测试图跑一遍,把标注图拼成一张大图快速扫一眼,比看数字可靠得多。

4.4 类别顺序与标签读取

类别名不要硬编码。把labels文件读进数组后,绘制时用classNames[ClassId]取名称。DocLayout-YOLO的类别通常是title、plain text、figure、table、table caption、figure caption这类,但不同权重的顺序可能不同,硬编码的代价就是画错标签后反复查代码。

实际排查最快的路径是:找一张有标题、有正文、有表格的样例图,把检测结果的类别ID和坐标全部打印出来,再叠到原图上对照。只要类别ID和视觉内容对不上,第一反应就该去查labels顺序,而不是怀疑模型没训练好。这类问题十次里有八次是标签顺序不一致造成的。

5. 部署避坑与常见问题:五个我实际踩过的坑

5.1 现象:推理结果全为0

第一次跑通C#推理时,输出数组长度正确,但所有置信度都接近0,过滤后一个框都没有。原因是我在预处理里把像素值除以255之后,又减了ImageNet的均值除以标准差,这是分类模型的习惯,但YOLO系列训练时只做0到1缩放,多加两步标准化之后,特征分布和训练时完全不同,置信度自然塌缩。

解决方法是严格按YOLO惯例做预处理:RGB顺序、除以255、不做均值方差归一化。我后来每次新拿到一个YOLO系列模型,都会先查README或者代码里的数据增强部分,确认推理时的归一化方式,再写C#预处理。

5.2 现象:CPU推理耗时三秒

一张普通A4扫描件在i5机器上要跑三秒多,明显不正常。排查后发现SessionOptions用的是默认配置,OnnxRuntime没有做图优化,线程数也吃满了所有逻辑核。另外,模型导出时的imgsz是1280,而测试图是4000像素长图,小图也被强行放大到1280推理。

解决方法是把OptimizationLevel设成ORT_ENABLE_ALL,IntraOpNumThreads设为物理核数的一半,然后把导出尺寸固定为640。如果业务上确实需要1280的召回效果,建议单独跑一次基准测试,别默认上大分辨率。

5.3 现象:检测框整体偏移且宽高变形

检测框画在原图上,整体往右下角偏,而且越靠近右下角偏得越多。原因是预处理直接用了Bitmap的Resize,把非正方形原图拉伸到了640x640,没有做Letterbox,原图的宽高比被破坏,模型输出的坐标映射回原图时就对不上。

解决方法是回退到Letterbox预处理,记录Scale、PadX、PadY,后处理时还原。从那以后我再也不敢直接Resize了,无论模型输入尺寸是什么,Letterbox都是一定要走的一步。

5.4 现象:每次推理都卡顿且内存持续增长

程序每处理一张图就卡一下,内存占用一路往上走。原因是每次推理前都new了一个InferenceSession,OnnxRuntime每次创建Session都要重新加载模型、做图优化和内存规划,这部分开销比推理本身还大。同时,Bitmap和Graphics对象没有释放,内存自然只增不减。

解决方法是把Session做成全局单例,程序启动时创建一次。Bitmap、Graphics、Pen、Font这些实现了IDisposable的对象,用using包裹或显式Dispose。C#里做图像推理,内存泄漏十有八九是Bitmap和Graphics没释放。

5.5 现象:WPF多线程调用时崩溃

在WPF里用Task.Run并发处理多张图片,偶尔会抛AccessViolationException,而且不是每次都能复现。原因是同一个InferenceSession在多个线程上同时调用Run,虽然OnnxRuntime官方说Session.Run是线程安全的,但DML执行提供程序在部分显卡驱动上并发还是会有问题,再加上代码里多个线程同时操作同一个Bitmap对象,踩了GDI+非线程安全的坑。

解决方法是把推理放到一个独立的消息队列或Worker线程里串行执行,或者用lock包住Session.Run。我实际项目里是建了一个SemaphoreSlim(1,1),保证同一时刻只有一个推理任务在跑,UI线程只负责显示结果和响应用户操作。

6. 把版面检测变成结构化JSON:业务接入验证技巧

6.1 定义版面结果模型

检测框最终要进业务系统,建议直接定义结构化结果对象,不要到处传List 。把类别、置信度、归一化坐标都放在一起,后面接OCR或者文档归档都方便。

public class LayoutItem { public string Category { get; set; } public float Confidence { get; set; } public float X { get; set; } public float Y { get; set; } public float Width { get; set; } public float Height { get; set; } } public class PageLayout { public string Source { get; set; } public List<LayoutItem> Items { get; set; } }

6.2 按阅读顺序排序并输出JSON

文档版面检测的框是乱的,业务端通常需要按阅读顺序输出。一个简单有效的做法是先按Y坐标分带,每带高度50像素左右,带内再按X坐标排序。对多栏排版,分带后从左到右读,基本能还原阅读顺序。

foreach (var group in items.GroupBy(i => (int)(i.Y / 50f))) { foreach (var item in group.OrderBy(i => i.X)) { jsonItems.Add(item); } }

最后用System.Text.Json序列化页面对象,输出给下游OCR或文档系统。排序不完美,但比随机输出靠谱得多。

6.3 验证技巧

我习惯把每张测试图跑出来的检测结果存成JSON,再和标注图一起归档。下次改模型或调阈值时,直接对比两版JSON的IoU和类别命中率,就知道改动是变好还是变坏了。这个方法比肉眼对比快,也能在回归测试里自动指出哪个区域检测丢了。最后说句实在话,C# OnnxRuntime部署DocLayout-YOLO本身不难,难的是输入输出确认和坐标还原这些不起眼的细节。从那以后我每次拿到一个YOLO系列ONNX模型,都会强制自己先跑一遍元数据打印,再做一轮带标注图的完整验证,希望帮到你少走这些弯路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询