尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

C#调用OnnxRuntime部署DocLayout-YOLO实现文档版面分析实战

C#调用OnnxRuntime部署DocLayout-YOLO实现文档版面分析实战 简介面向需要在 C#/.NET 环境中集成文档版式分析能力的中高级开发人员这份压缩包提供了一套基于 OnnxRuntime 部署 DocLayout-YOLO 的完整工程。DocLayout-YOLO 是在 YOLOv10 基础上引入 Mesh-candidate BestFit 合成预训练和全局到局部自适应感知模块的检测模型能够对论文、合同、报表等复杂版面中的标题、表格、图片、正文区域进行实时定位资源内含可直接运行的 C# 项目源码、ONNX 模型文件及运行时依赖可帮助读者解决 WPF/ASP.NET Core 等场景下的本地化模型推理问题。资源共 325 个文件容量约 463.21MB。文件以 dll、onnx/onnxruntime 等动态库与模型文件为主同时包含 cs 调用代码、xml 配置、props/nupkg 工程配置以及 jpg/png 示例图片和 txt 说明文档可支撑工程编译、模型加载与效果验证等环节。目前已有 329 人学习或下载。使用该资源可省去从零搭建环境的成本直接获得预处理、推理、后处理的参考实现适合需要快速将 DocLayout-YOLO 接入 C# 业务系统的算法工程师。若想了解合成数据生成、全局到局部感知等模型细节也可结合资源中的说明文档与示例图片快速对照。1. 用C#调OnnxRuntime跑DocLayout-YOLO先解决“文档里的框从哪来”做上位机或者文档系统的工程师迟早会撞上一个需求把扫描件、PDF转出来的图片、试卷截图里的“标题、正文、表格、图片、公式”一个个框出来。以前的做法是接Python服务或者用OCR厂商的接口但本地部署、离线识别、C#一把梭的场景里最顺手的方案其实是拿C#配合OnnxRuntime直接加载DocLayout-YOLO模型在进程内完成推理。DocLayout-YOLO是专门做文档版面分析的YOLO系列模型能识别文本块、标题、表格、图片、公式等区域而OnnxRuntime让C#不必起Python进程直接把onnx模型文件塞进推理会话里跑。这个方案适合三类人一是写C#上位机、桌面工具不想额外维护Python环境的二是做文档归档、试卷切割、票据分类需要把版面区域当预处理步骤的三是想在本地离线跑推理不想把文档内容送出内网的。它解决的核心问题不是“能不能识别”而是“C#项目里怎么稳定地跑通一个版面检测模型”。这篇文章从选型讲到后处理把坐标映射、NMS、CPU/GPU执行提供方这些容易翻车的点全部摊开尽量让你照着写就能复现。2. 为什么是OnnxRuntime DocLayout-YOLO而不是其他组合2.1 DocLayout-YOLO的模型本质它输出的是“框”而不是“字”先搞清楚DocLayout-YOLO到底给你什么。它是一个一阶段目标检测模型基于YOLO架构改进而来专门针对文档版面设计了数据合成策略。它的输出不是OCR文字而是文档图像上的矩形区域每个区域带一个类别标签和置信度。常见的类别有10个title、plain text、abandon、figure、figure caption、table、table caption、table footnote、isolate formula、formula caption。也就是说它告诉你“这张图片的左上角到右下角是一张表格”“中间偏下是一段正文”但表格里写了什么字它不管那是OCR的活。这个分工很重要。实际落地时DocLayout-YOLO通常作为OCR管线的前置步骤先用它把版面切成不同区域再对每个区域单独做文字识别。这样做有两个好处一是避免OCR引擎把表格线和正文文字混在一起二是可以按区域类型分配不同的识别策略比如公式区域用公式识别模型表格区域用表格结构还原模型。如果你需要的是“C# OCR PDF”那种整体识别效果DocLayout-YOLO只是其中一块拼图但它决定了后续识别的边界质量。2.2 C#这边的推理框架选型OnnxRuntime是事实标准C#里跑深度模型可选方案其实不多。TorchSharp能加载PyTorch模型但生态和性能调优都麻烦TensorFlow.NET基本处于维护状态OpenCvSharp自带的DNN模块能读onnx但API偏底层NMS要自己写而且对动态输入的支持不如专用推理库。最顺手的还是Microsoft.ML.OnnxRuntime也就是OnnxRuntime的C#绑定。原因有几个它和Python版共用同一套底层执行引擎模型行为和Python端几乎一致支持CPU、CUDA、DirectML多种执行提供方NuGet包直接引入不需要额外装运行时。如果你手里的DocLayout-YOLO模型是PyTorch导出的onnxOnnxRuntime都能直接加载不需要PyTorch环境。这比TorchSharp省心得多因为DocLayout-YOLO的导出模型通常已经固定了输入输出节点你只需要关心预处理和后处理不用管模型内部的网络结构。我用OnnxRuntime部署YOLO系列的惯例是先看模型的输入输出签名再决定预处理怎么写而不是想当然地按YOLOv5的老套路全套照搬。2.3 跑通前的环境准备NuGet包与模型文件在Visual Studio里新建一个.NET 6或.NET 8的控制台项目然后通过NuGet安装三个包Microsoft.ML.OnnxRuntime推理引擎、OpenCvSharp4图像读取与缩放、OpenCvSharp4.runtime.winOpenCvSharp的原生依赖。如果只做推理不显示窗口不需要装OpenCvSharp4.Windows那个大包。我的习惯是先把这三个包装上再准备模型文件。模型文件方面DocLayout-YOLO的onnx导出文件一般可以从模型仓库拿到。拿到后第一步不是直接写代码而是用Python或者Netron看一眼输入输出。Netron打开模型后输入节点通常显示为一个四维张量输出可能是[1, N, 6]或[1, N, 84]这类的格式。1, N, 6表示N个候选框每个候选框是x1, y1, x2, y2, score, class_id1, N, 84表示N个候选框每个包含x, y, w, h加80个类别分数。DocLayout-YOLO的导出版本常见的是带NMS的端到端输出也就是1, N, 6。不过这不能靠猜一定要用代码把输出shape打印出来确认这一步能帮你躲掉后面最坑的雷。# 如果你需要快速验证onnx模型的基本信息可以用Python命令行工具 python -m onnxruntime.tools.make_dynamic_shape_fixed --help这个命令只是辅助工具不完全必要。更直接的做法是写几行C#代码读取模型的输入输出元数据我会在下一章代码里一起给出。3. 最小可跑通的推理管线从Bitmap到检测框3.1 Letterbox预处理等比例缩放不丢版面结构YOLO系列模型的输入通常是正方形常见尺寸是640x640或1280x1280。DocLayout-YOLO对高分辨率文档更友好因为文档里的文字区域很小缩得太小会直接丢失。但直接拉伸会把表格线和文字压变形所以要用Letterbox方式等比例缩放后在两侧填充灰色边补齐到正方形。private static (Mat resized, float ratio, int padLeft, int padTop) Letterbox(Mat src, int targetSize) { int w src.Width; int h src.Height; // 取缩放比例目标尺寸除以原图宽高取较小值保证完整容纳 float ratio Math.Min((float)targetSize / w, (float)targetSize / h); int newW (int)Math.Round(w * ratio); int newH (int)Math.Round(h * ratio); Mat resized new Mat(); Cv2.Resize(src, resized, new Size(newW, newH)); // 计算需要填充的尺寸左右各一半上下各一半 int padW targetSize - newW; int padH targetSize - newH; int padLeft padW / 2; int padTop padH / 2; Mat canvas new Mat(targetSize, targetSize, src.Type(), new Scalar(114, 114, 114)); resized.CopyTo(canvas[new Rect(padLeft, padTop, newW, newH)]); return (canvas, ratio, padLeft, padTop); }这段代码里有两个参数要重点说。第一个是targetSize如果模型输入是640就传640如果是1280就传1280。不要传一个模型不接受的尺寸否则OnnxRuntime会直接报输入张量形状不匹配。第二个是填充值Scalar(114, 114, 114)这是YOLO系列统一用的灰色填充取114这个值是为了和训练时保持一致。如果你自己训练过模型这里的填充值必须和训练脚本里的pad_value一致否则推理结果会差一点。Letterbox的返回值里ratio、padLeft、padTop是后面坐标还原的关键很多人的检测框位置偏了就是在这里把这三个值弄丢了。3.2 OnnxRuntime会话加载与推理读取输入输出签名模型加载和推理的代码比较固定但有一个动作不能省打印输入张量的维度。前面说过有的onnx输入是1x3x640x640NCHW有的导出时被转成了1x640x640x3NHWC这两种格式在填充像素数据时的顺序完全不同。using Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime.Tensors; public class DocLayoutYoloDetector : IDisposable { private readonly InferenceSession _session; private readonly string _inputName; private readonly int _channels; private readonly int _height; private readonly int _width; private readonly string[] _classNames; public DocLayoutYoloDetector(string modelPath, string[] classNames) { _session new InferenceSession(modelPath); _classNames classNames; // 读取输入节点信息动态获取输入名称和维度 var inputMeta _session.InputMetadata.First(); _inputName inputMeta.Key; var dims inputMeta.Value.Dimensions; // YOLO类模型一般是4维[batch, channel, height, width] 或 [batch, height, width, channel] _channels dims[1]; // 先默认NCHW _height dims[2]; _width dims[3]; // 如果第二维不是3而第四维是3说明是NHWC格式 if (dims[1] ! 3 dims[3] 3) { _channels dims[3]; _height dims[1]; _width dims[2]; } Console.WriteLine($输入节点: {_inputName}, 维度: {string.Join(,, dims)}); } public void Dispose() _session.Dispose(); }这段代码里有个关键的判断逻辑读取输入维度后如果第2维不是3但第4维是3就把它当成NHWC。这个操作看起来多余但实际部署时非常有用。DocLayout-YOLO的官方导出模型大多是NCHW但有些第三方导出脚本会转成NHWC你如果拿着NHWC的模型按NCHW填数据推理结果会是一堆乱框而且不报错非常难排查。classNames参数是类名数组必须和模型训练时的类别顺序一致。DocLayout-YOLO的常见10类顺序为[title, plain text, abandon, figure, figure caption, table, table caption, table footnote, isolate formula, formula caption]。这里abandon指的是废弃区域比如页眉页脚、装饰线、水印之类的噪声实际使用中可以在后处理时直接过滤掉但别把它从数组里删掉因为模型输出的类别索引是按这个顺序排列的。3.3 推理调用与输出解析按输出shape分流处理图像预处理完成后要把Mat里的像素数据转换成模型需要的张量。这里涉及两个转换一是像素排列从HWC转成CHW二是像素值从0到255缩放到0到1。OnnxRuntime的DenseTensor可以直接承载这些数据。public ListDetectionResult Detect(Mat image) { // 1. Letterbox缩放 var (letterboxed, ratio, padLeft, padTop) Letterbox(image, _width); // 2. 准备输入张量 var inputTensor new DenseTensorfloat(new[] { 1, _channels, _height, _width }); unsafe { // 获取Mat的像素数据指针逐像素转存 using var matData letterboxed.GetArray(out byte[] pixelData); for (int h 0; h _height; h) { for (int w 0; w _width; w) { int pixelOffset (h * _width w) * 3; // BGR顺序 // 注意OpenCvSharp是BGR模型训练时一般是RGB inputTensor[0, 0, h, w] pixelData[pixelOffset 2] / 255.0f; // R inputTensor[0, 1, h, w] pixelData[pixelOffset 1] / 255.0f; // G inputTensor[0, 2, h, w] pixelData[pixelOffset 0] / 255.0f; // B } } } // 3. 推理 var inputs new ListNamedOnnxValue { NamedOnnxValue.CreateFromTensor(_inputName, inputTensor) }; using var results _session.Run(inputs); var output results.First().AsTensorfloat(); // 4. 根据输出shape走不同的解析逻辑 return ParseOutput(output, ratio, padLeft, padTop); }这里有一处容易忽略但必须处理的细节OpenCvSharp读入图像是BGR顺序而大部分PyTorch模型训练用的是RGB。如果不转换检测框的位置可能整体偏移或者对红色和蓝色的区域识别错误。我在上面的代码里把pixelData[pixelOffset 2]BGR中的R赋给了第一个通道就是做这个转换。inputTensor的构造写法是new DenseTensorfloat(new[] { 1, _channels, _height, _width })这里的通道数、高度、宽度必须和模型输入完全一致。如果你把模型输入读错了比如实际是NHWC但你按NCHW建张量运行时会报错。这也是为什么前面要先打印输入维度的原因。3.4 后处理NMS、坐标还原、过滤低置信度模型输出有两种常见形态。第一种是已经做完NMS的端到端模型输出shape是[1, N, 6]N是检测框数量每个框是x1, y1, x2, y2, score, class_id。第二种是原始输出shape是[1, M, 4 num_classes]M是锚框总数需要自己解析坐标和类别分数再做NMS。private ListDetectionResult ParseOutput(Tensorfloat output, float ratio, int padLeft, int padTop) { var results new ListDetectionResult(); int rows output.Dimensions[1]; int cols output.Dimensions[2]; Console.WriteLine($输出shape: {string.Join(,, output.Dimensions.ToArray())}); // 分支1已经带NMS的输出每行6个值 [x1,y1,x2,y2,score,cls] if (cols 6) { for (int i 0; i rows; i) { float score output[0, i, 4]; if (score 0.45f) continue; int cls (int)output[0, i, 5]; float x1 output[0, i, 0]; float y1 output[0, i, 1]; float x2 output[0, i, 2]; float y2 output[0, i, 3]; results.Add(new DetectionResult { Label _classNames[cls], Confidence score, Box new RectF( (x1 - padLeft) / ratio, (y1 - padTop) / ratio, (x2 - x1) / ratio, (y2 - y1) / ratio) }); } return results; } // 分支2原始输出需要自己做NMS这里省略NMS实现用简单TopK代替 // 实际工程中用下文的NmsFilter方法处理 var boxes new ListDetectionResult(); for (int i 0; i rows; i) { float score output[0, i, 4]; for (int c 0; c cols - 4; c) { float classScore output[0, i, c 4]; ... } } return NmsFilter(boxes, 0.45f, 0.5f); }分支1是最理想的情况模型导出时就把NMS做进去了C#这边省掉一大段后处理代码。坐标还原就两句话(x - padLeft) / ratio和(y - padTop) / ratio。先减padLeft把填充区域去掉再除以缩放比例得到的就是原图坐标系下的坐标。很多人只做了除以ratio结果每个框都往右下角偏了一段距离这就是没减padLeft和padTop的症状。分支2需要自己写NMS。YOLOv8的导出还真不一定带NMS取决于转换时的配置。如果你拿到的模型输出维度是几百乘以类别数加4那就要老老实实写NMS。NMS的代码不复杂就是反复挑最高分的框然后删除和它IOU过大的候选框。整个过程用C#写大约30行下面是我常用的实现。3.5 NMS与结果画框用OpenCvSharp把检测框画回原图自己实现NMS时一个容易写错的地方是坐标的顺序。如果PyTorch用的是YOLOv8的xywh格式你要先把它转成xyxy再做IOU计算如果导出时已经是xyxy就直接用。为了避免这种不确定性我会在解析时先统一转成xyxy再进NMS。private ListDetectionResult NmsFilter(ListDetectionResult candidates, float confThresh, float iouThresh) { var valid candidates.Where(c c.Confidence confThresh) .OrderByDescending(c c.Confidence) .ToList(); var results new ListDetectionResult(); while (valid.Count 0) { var best valid[0]; results.Add(best); valid.RemoveAt(0); // 计算当前框与剩余框的IOU过滤掉重叠过大的 valid valid.Where(c Iou(best.Box, c.Box) iouThresh).ToList(); } return results; } private static float Iou(RectF a, RectF 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 interW Math.Max(0, x2 - x1); float interH Math.Max(0, y2 - y1); float interArea interW * interH; float unionArea a.Width * a.Height b.Width * b.Height - interArea; return unionArea 0 ? 0 : interArea / unionArea; }confThresh我通常设0.45这个值可以在“漏检”和“误检”之间找一个折中。文档版面检测和通用目标检测不一样文档里的标题、表格边界往往很清晰置信度普遍偏高所以阈值可以适当提高过滤掉那些模棱两可的检测框。iouThresh设0.5比较稳妥如果发现重叠框太多可以调到0.4如果发现同一区域被重复检测也可以降到0.3。画框回原图这一步很简单但有一个注意点OpenCvSharp的PutText不支持中文直接写中文标签会变成乱码。我的做法是画框时不写中文框旁边写类名索引或者英文类名存储结果时再用System.Drawing把中文标注画到一个新图层上或者输出JSON让前端负责渲染。public Mat DrawBoxes(Mat image, ListDetectionResult results) { var result image.Clone(); foreach (var box in results) { Cv2.Rectangle(result, new Rect((int)box.Box.X, (int)box.Box.Y, (int)box.Box.Width, (int)box.Box.Height), Scalar.Red, 2); Cv2.PutText(result, box.Label, new Point((int)box.Box.X, (int)box.Box.Y - 5), HersheyFonts.HersheySimplex, 0.6, Scalar.Lime, 2); } return result; }到了这里一条最小推理管线就算完整跑通了读图、Letterbox、转张量、推理、解析、NMS、画框。整套代码大概150行左右主流程就集中在Detect和DrawBoxes两个方法里。如果你把它接到上位机界面上只需要把输入从图片文件换成摄像头帧或者PDF渲染出的Bitmap即可。4. 避坑部署DocLayout-YOLO常见的5个翻车点4.1 模型输入尺寸和预处理尺寸不一致现象推理不报错但检测框位置全部错乱有的框超出图像边界有的框缩在角落。原因模型实际输入是1280你按640做的Letterbox输入张量被OnnxRuntime自动resize了但坐标映射用的缩放比例是640的导致全部偏移。解决打印输入元数据后把Letterbox的targetSize参数固定成读取到的_width和_height不要硬编码。我在代码里直接用了_width作为targetSize就是避免这个问题。4.2 OpenCvSharp的BGR通道顺序导致彩框偏移现象红色表格框出来的区域偏在左边蓝色标题框偏右边。原因模型训练时用RGBC#推理时没有做通道转换直接把BGR数据塞进去。解决在构造DenseTensor时把通道顺序换过来即pixelData[pixelOffset 2]赋给通道0pixelData[pixelOffset 1]赋给通道1pixelData[pixelOffset 0]赋给通道2。这个坑在灰度图上不影响在彩色文档上特别明显排查时可以拿一张纯红色的图测试。4.3 OnnxRuntime版本太旧加载模型报错现象InferenceSession构造时抛异常提示EP Error或Invalid model。原因DocLayout-YOLO的onnx导出时可能用了较新的opset旧版OnnxRuntime不支持。解决把Microsoft.ML.OnnxRuntime升级到最新稳定版或者至少1.16以上。另外如果模型是动态尺寸导出的还要确认OnnxRuntime版本支持动态shape。这类问题没有技巧看异常信息里的opset编号升级即可。4.4 NMS后处理坐标错乱提交xywh给IOU计算现象过滤后的检测框数量明显偏少且保留的框都是大框。原因解析输出时直接用了xywh格式参与IOU计算但IOU函数按xyxy格式计算把宽度当成了x2高度当成了y2。解决进IOU之前先统一转换格式或者IOU函数里先处理坐标变换。我的习惯是先定义一个ToXyxy方法所有候选框先转一遍再算IOU这样不管模型输出是哪种格式后面的NMS逻辑都不用改。4.5 推理时间不稳定偶尔一次特别慢现象同一张图片大多数时候推理40毫秒偶尔一次跳到500毫秒。原因OnnxRuntime默认会做内存池优化但在CPU执行环境下第一次推理会触发模型图优化和内存分配后续才变快。如果你每次都新建InferenceSession等于每次都把优化流程走一遍。解决把InferenceSession设计成单例长生命周期复用。如果是多线程并发调用注意OnnxRuntime的会话默认不是线程安全的要么加锁要么每个线程创建独立会话。我的做法是线程池里每个线程一个会话测试下来的吞吐比单会话加锁高很多。5. 从“能跑”到“跑得好”性能调优与输出验证推理管线跑通之后通常有两个方向要优化一是延迟二是准确性验证。先说延迟。OnnxRuntime在CPU上的性能瓶颈主要在Run调用阶段预处理和后处理加起来占比不高。我一般会调两个参数设置SessionOptions的图优化级别为全部以及设置合适的线程数。图优化级别通过SessionOptions.GraphOptimizationLevel GraphOptimizationLevel.ORT_ENABLE_ALL开启线程数通过SessionOptions.SetSessionThreads(Environment.ProcessorCount)设置。注意线程数不是越大越好YOLO推理的计算模式是矩阵乘法线程过多反而导致内存带宽竞争。我在10代i5上的经验是设4到6线程效果最好。如果项目里已经有CUDA显卡可以换Microsoft.ML.OnnxRuntime.Gpu包然后在会话选项里追加CUDA执行提供方。但DocLayout-YOLO的模型体量并不大CPU推理一张640输入的图通常40到80毫秒1280输入的图也就150毫秒左右如果你的场景是文档扫描归档这种离线批量处理CPU完全够用不用为了它去折腾CUDA环境。如果是做实时扫描仪的分流一张纸从进纸到出结果控制在300毫秒内CPU也能支撑。验证模型部署是否正确最靠谱的方法是对比输出。拿同一张测试图片分别跑Python版OnnxRuntime和C#版推理把检测框导出成JSON然后逐项比较坐标和置信度。误差超过1个像素就要排查预处理差异常见差异点是Letterbox的取整方式不同。Python端一般用math.floorC#的Math.Round默认是银行家舍入两者在奇数像素补边时可能差1个像素。这个误差对检测框影响不大但如果你做的是像素级后处理比如把检测区域切图送OCR这1个像素可能导致表格边框被切掉。我的习惯是C#端统一用(int)(value 0.5f)的方式做四舍五入保证和Python端一致。最后一个建议是把输入输出shape的调试信息固化到日志里。模型文件换了、导出脚本改了、别人给你发了一份新模型这些情况在工程里太常见了。每次程序启动时打印输入节点的名称、维度、输出节点的维度能帮你节省大量排查时间。我自己就吃过亏换了一份带NMS的导出模型旧代码按原始输出解析结果所有框的类别全对不上号最后才发现是输出shape从[1, 8400, 84]变成了[1, 300, 6]。从那以后所有部署YOLO模型的代码我都把输入输出签名打在最前面。希望这篇文章能让你在C#里跑DocLayout-YOLO时少走这些弯路。本文还有配套的精品资源点击获取
返回列表