ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C# OnnxRuntime部署DocLayout-YOLO:文档版面分析实战全流程

C# OnnxRuntime部署DocLayout-YOLO:文档版面分析实战全流程 简介面向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 modeldoclayout_yolo.pt formatonnx opset12 dynamicFalse imgsz640这里的dynamicFalse表示输入尺寸固定为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加上类别数再加置信度比如412117那就是常规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底层通常是BGRLockBits读出来之后要交换通道。下面这段是完整的张量转换static DenseTensorfloat ToTensor(Bitmap bmp, int inputSize 640) { var tensor new DenseTensorfloat(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。推理调用节点名称用第一步确认的imagesusing var results session.Run(new ListNamedOnnxValue { NamedOnnxValue.CreateFromTensor(images, tensor) }); var output results.First(r r.Name output0).AsTensorfloat().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版OnnxRuntimeSession设置逻辑完全一样只是包版本要选对应架构。4. 后处理与标注渲染置信度过滤、NMS和类别映射4.1 从1x300x6解析检测框OnnxRuntime输出的float数组是一维的长度1800。按行优先排列每行6个元素中心点x、中心点y、宽、高、置信度、类别ID。第一步先把低置信度的候选去掉var dets new ListDetection(); 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 ListDetection Nms(ListDetection dets, float iouThreshold 0.45f) { var result new ListDetection(); 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, ListDetection 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_ALLIntraOpNumThreads设为物理核数的一半然后把导出尺寸固定为640。如果业务上确实需要1280的召回效果建议单独跑一次基准测试别默认上大分辨率。5.3 现象检测框整体偏移且宽高变形检测框画在原图上整体往右下角偏而且越靠近右下角偏得越多。原因是预处理直接用了Bitmap的Resize把非正方形原图拉伸到了640x640没有做Letterbox原图的宽高比被破坏模型输出的坐标映射回原图时就对不上。解决方法是回退到Letterbox预处理记录Scale、PadX、PadY后处理时还原。从那以后我再也不敢直接Resize了无论模型输入尺寸是什么Letterbox都是一定要走的一步。5.4 现象每次推理都卡顿且内存持续增长程序每处理一张图就卡一下内存占用一路往上走。原因是每次推理前都new了一个InferenceSessionOnnxRuntime每次创建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 ListLayoutItem 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模型都会强制自己先跑一遍元数据打印再做一轮带标注图的完整验证希望帮到你少走这些弯路。本文还有配套的精品资源点击获取
返回列表