
简介这套基于C#与ONNX Runtime的YOLOv8建筑物分割源码面向需要在.NET环境部署实例分割模型的开发者适用于无人机航拍、智慧城市等建筑物轮廓提取场景可帮助中高级技术人员绕开繁琐的跨语言配置直接聚焦模型调用与业务集成。压缩包共246个文件、约277.36MB除12个C#核心工程文件与1个ONNX模型外还内置43个DLL运行依赖、17个头文件、22个TXT说明、16个构建目标配置以及SO/Dylib等跨平台库目录结构清晰便于按需调用。已有339人学习下载对希望参考现成C#分割工程、快速获得端侧部署经验的读者有较高实用价值。通过该资源可拿到可直接编译运行的示例项目其中封装了图像加载、归一化预处理、分割推理、掩码后处理与结果显示等完整流程并附有构建脚本与NuGet依赖配置能显著降低在C#中集成ONNX模型的门槛适合在Visual Studio中快速部署调试。1. 直接拿 C# 跑 YOLOv8 建筑分割难的不是模型而是输出解析很多人以为把 YOLOv8 的 pt 权重导出成 onnx再在 C# 里用 OnnxRuntime 加载就算完成了。实际做一次 building segmentation 就会发现模型输出不是直接给你一张掩码图而是两组张量一组是原型掩码一组是掩码系数。真正的分割结果要靠矩阵乘法把两者组合出来还要做坐标映射和 NMS。这套流程在 Python 里三五行搞定但换成 C# 手写时张量维度、内存布局、Resize 对齐都会变成坑。这篇源码给你拆的就是这一整条链路适合已经在用 YOLOv8 做目标检测、现在想扩展到实例分割的 C# 开发者。它会跳过环境安装废话直接讲清楚 ONNX 输出怎么解析、掩码怎么生成、建筑轮廓怎么还原以及我在调试过程中遇到的几个最隐蔽的 bug。2. YOLOv8 分割模型输出结构与 ONNX 导出参数2.1 输出张量的维度含义YOLOv8-seg 模型在导出为 onnx 后输出通常是两个特征图。以输入 640x640、默认 80 个类别为例第一个输出的 shape 是[1, 116, 8400]其中 116 4框坐标 80类别概率 32掩码系数。第二个输出是[1, 32, 160, 160]这是原型掩码。注意这里的 8400 是三个尺度特征图80x80、40x40、20x20展平后的锚点总数。如果是你自己的训练权重类别数变了第一个输出的通道数就相应变成4 num_classes 32。建筑分割场景通常只有一个类别也就是[1, 37, 8400]。很多人会照着检测模型的代码去读前 4 个值作为框然后拿后面的值直接当类别概率最后发现分割结果完全对不上。原因就是掩码系数的 32 个值混在类别概率后面必须按维度切出来否则类别概率和掩码系数错位后续的掩码生成全是噪声。2.2 导出时的关键参数与陷阱用 ultralytics 导出 onnx 时我一般用下面这组命令它对后续 C# 解析最友好yolo export modelbuilding-seg.pt formatonnx opset12 simplifyTrue dynamicFalse imgsz640这里dynamicFalse是为了固定输入尺寸C# 端不用处理动态轴simplifyTrue会去掉一些多余的计算节点减少 Runtime 加载时间。opset 12 对 OnnxRuntime 的兼容性最好opset 太高的话老版本 C# NuGet 包可能不支持。导出后先用 Python 的 onnxruntime 跑一遍用session.get_outputs()[i].name打印输出名C# 里获取输出时要严格用这些名字。还有一个容易忽略的点ultralytics 导出的 onnx 输出顺序不一定是[框类别掩码系数, 原型掩码]这个顺序。我在实际项目中见过输出顺序反过来的情况所以在 C# 里不要靠索引判断而应该用输出名去取。输出名一般是output0和output1但保险起见用下面这段代码打印import onnxruntime as ort sess ort.InferenceSession(building-seg.onnx) for o in sess.get_outputs(): print(o.name, o.shape)2.3 从 ONNX 输出到掩码的数学原理YOLOv8 的掩码生成公式不复杂每个检测框对应的掩码系数32 维向量与原型掩码做矩阵乘法。// 假设 coefficients 是 float[32]prototypes 是 float[160*160] // 生成 160x160 的 mask float[] mask new float[160 * 160]; for (int i 0; i 160 * 160; i) { float sum 0; for (int j 0; j 32; j) { sum prototypes[i * 32 j] * coefficients[j]; // 注意内存布局 } mask[i] 1.0f / (1.0f MathF.Exp(-sum)); // sigmoid }这段代码里的张量布局是关键。ONNX Runtime 的result返回的是按行主序存储的连续数组原型掩码的 shape 是[1, 32, 160, 160]内存里实际顺序是先 32 个通道每个通道内是 160x160 的图像数据。所以访问mask[i][j]对应到扁平数组的索引是i * 25600 j而不是i * 32 j。上面代码简化成了按i*32j取那是假设布局是[160*160, 32]实际会错得很离谱。正确做法是先按[32, 160*160]读取再转置或者干脆用矩阵乘法库。提示如果你用 ML.NET 或 NumSharp直接用矩阵乘法prototype_matrix * coefficient_vector避免手写循环时搞错索引顺序。3. C# 项目搭建与 OnnxRuntime 推理实现3.1 NuGet 包选择与项目配置我用的项目框架是 .NET 8OnnxRuntime 的 NuGet 包版本选了Microsoft.ML.OnnxRuntime.Gpu1.17.1因为要跑 CUDA 加速。如果只在 CPU 上调试用Microsoft.ML.OnnxRuntime就够了包体更小加载也快。GPU 包需要额外匹配 CUDA 11.8 和 cuDNN 8.x这一点经常有人踩坑版本不匹配时直接报DllNotFoundException。为了兼容 OpenCV 做图像处理我还引用了OpenCvSharp4和OpenCvSharp4.runtime.win。图像读取、letterbox 缩放、结果叠加全靠它避免自己写像素操作。3.2 输入预处理Letterbox 与归一化YOLOv8 的输入需要做 letterbox 等比例缩放把长边缩放到 640短边补灰边到 640。这一步必须保证缩放比例和 padding 信息在输出端能还原。C# 代码里我用 OpenCvSharp 实现public static Mat LetterBox(Mat src, int targetSize, out float ratio, out int padX, out int padY) { int srcW src.Width; int srcH src.Height; ratio Math.Min((float)targetSize / srcW, (float)targetSize / srcH); int newW (int)Math.Round(srcW * ratio); int newH (int)Math.Round(srcH * ratio); padX (targetSize - newW) / 2; padY (targetSize - newH) / 2; Mat resized new Mat(); Cv2.Resize(src, resized, new OpenCvSharp.Size(newW, newH)); Mat canvas new Mat(targetSize, targetSize, MatType.CV_8UC3, new Scalar(114, 114, 114)); var roi new OpenCvSharp.Rect(padX, padY, newW, newH); resized.CopyTo(new Mat(canvas, roi)); Mat rgb new Mat(); Cv2.CvtColor(canvas, rgb, ColorConversionCodes.BGR2RGB); Mat floatImg new Mat(); rgb.ConvertTo(floatImg, MatType.CV_32FC3, 1.0 / 255.0); // HWC - CHW var tensor new DenseTensorfloat(new[] { 1, 3, targetSize, targetSize }); var data tensor.Buffer.Span; unsafe { var p (byte*)floatImg.DataPointer; for (int c 0; c 3; c) for (int h 0; h targetSize; h) for (int w 0; w targetSize; w) data[c * targetSize * targetSize h * targetSize w] p[(h * targetSize w) * 3 c]; } return canvas; // 返回原图大小的 canvas 用于后续坐标还原 }这里返回的canvas是 letterbox 后的图像方便后面可视化时直接叠加。实际上我们只需要它的宽高和 padding 值做坐标映射所以也可以通过 out 参数传回 padX 和 padY。我特意在函数里同时返回了 canvas调试时可以直接看输入是否正确。HWC 到 CHW 的转换是性能热点用 unsafe 指针操作比Marshal.Copy快不少。注意DenseTensor的索引顺序和 ONNX Runtime 的输入要求一致都是 NCHW。3.3 创建 Session 并执行推理OnnxRuntime 的 Session 初始化开销很大我一般在程序启动时创建一个全局静态实例后续推理复用。注意配置SessionOptions时开启 GPU 和优化var options new SessionOptions { GraphOptimizationLevel GraphOptimizationLevel.ORT_ENABLE_ALL, EnableCpuMemArena true }; if (useGpu) { options.AppendExecutionProvider_CUDA(0); } _inferenceSession new InferenceSession(modelPath, options);执行推理时输入张量的名字是images类型是float[1, 3, 640, 640]。输出张量从 session 里拿不硬编码名字var inputMeta _inferenceSession.InputMetadata.Keys.First(); var outputs _inferenceSession.OutputMetadata.Keys.ToArray(); var input new ListNamedOnnxValue { NamedOnnxValue.CreateFromTensor(inputMeta, tensor) }; using (var results _inferenceSession.Run(input)) { var output0 results.First(x x.Name outputs[0]).AsTensorfloat(); var output1 results.First(x x.Name outputs[1]).AsTensorfloat(); // 注意这里输出顺序不固定需要用名字区分 }3.4 后处理从输出张量到检测框后处理是整个源码中最核心的部分我把步骤拆成五个阶段阶段输入输出关键操作1. 切分张量[1, 37, 8400]框(4)、类别(1)、系数(32)按行索引分割2. 置信度过滤类别概率候选索引阈值 0.253. NMS候选框最终框IoU 阈值 0.454. 掩码生成系数 原型原始尺寸掩码矩阵乘 sigmoid5. 坐标还原框/掩码原图坐标letterbox 逆变换第一步切分时要注意输出张量的 shape 是[1, 37, 8400]在 C# 里拿到的是三维数组。由于 OnnxRuntime 的AsTensorfloat()返回的是按行主序存储实际访问output[0, i, j]时扁平索引是i * 8400 j。我用一个简单的偏移表来读取float[] data output0.ToArray(); // 长度为 1*37*8400 const int numClasses 1; const int maskCoeffs 32; const int anchors 8400; Listfloat[] boxes new(); Listfloat[] coeffsList new(); Listfloat scores new(); for (int a 0; a anchors; a) { float maxScore 0; int classId -1; for (int c 0; c numClasses; c) { float score data[(4 c) * anchors a]; if (score maxScore) { maxScore score; classId c; } } if (maxScore 0.25) continue; float cx data[0 * anchors a]; float cy data[1 * anchors a]; float w data[2 * anchors a]; float h data[3 * anchors a]; // 保存 cx,cy,w,h 和系数 }这里需要特别小心每个锚点的位置不是按行连续存储的而是按属性分开的。也就是说框的中心 x、中心 y、宽、高分别在不同的二维行里。如果直接扁平化后顺序读取读到的第 0 个到第 8399 个是第一个属性的所有锚点8400 到 16799 是第二个属性。上面的代码已经处理了这种布局千万不要写成data[a * 37 c]那种按锚点连续的方式。NMS 我直接用了 OpenCvSharp 的Cv2.Dnn.NMSBoxesOpenCvSharp.Rect[] rects boxes.Select(b { float cx b[0], cy b[1], w b[2], h b[3]; return new OpenCvSharp.Rect((int)(cx - w / 2), (int)(cy - h / 2), (int)w, (int)h); }).ToArray(); float[] confidences scores.ToArray(); int[] indices; Cv2.Dnn.NMSBoxes(rects, confidences, 0.25f, 0.45f, out indices);NMS 出来后每个保留的检测框对应一组掩码系数下面就可以进入掩码生成阶段。提示OpenCvSharp 的 NMSBoxes 返回的是 int 数组注意它的第一个参数是 Rect 数组坐标需要是整数。如果你的框是浮点坐标四舍五入会影响小目标检测精度建议自己写一个基于 float 的 NMS或者把框放大十位取整再还原。4. 建筑分割掩码生成与坐标还原实战4.1 从原型掩码生成原始分辨率掩码拿到 NMS 后的检测框和对应的系数数组接下来用矩阵乘法生成 160x160 的掩码。这一步要避免我前面提到的索引陷阱所以我先把原型掩码转成一个[32, 25600]的浮点数组再和[1, 32]系数做点积public static Mat GenerateMask(float[] coeffs, float[] prototypes, int maskH 160, int maskW 160) { int area maskH * maskW; float[] mask new float[area]; for (int i 0; i area; i) { float sum 0; for (int c 0; c 32; c) { // prototypes 布局是 [1, 32, 160, 160]扁平索引 c * area i sum prototypes[c * area i] * coeffs[c]; } mask[i] 1.0f / (1.0f MathF.Exp(-sum)); } // 转成 MatCV_32FC1 Mat maskMat new Mat(maskH, maskW, MatType.CV_32FC1); Marshal.Copy(mask, 0, maskMat.DataPointer, area); return maskMat; }sigmoid 后的掩码数值范围在 0~1 之间接着用阈值 0.5 二值化。建筑分割中经常出现大建筑跨越多个锚点单个锚点的掩码可能只覆盖建筑的一部分NMS 会保留多个框最后在可视化时需要合并这些框的掩码结果。二值化之后还需要做一次形态学操作去掉小的噪点。我常用开运算核大小选 3x3迭代一次。这一步不是必需但建筑边缘如果出现破碎的像素点开运算能显著提升视觉质量。Mat binary new Mat(); Cv2.Threshold(maskMat, binary, 0.5, 255, ThresholdTypes.Binary); Mat kernel Cv2.GetStructuringElement(MorphShapes.Ellipse, new OpenCvSharp.Size(3, 3)); Cv2.MorphologyEx(binary, binary, MorphTypes.Open, kernel);4.2 将掩码坐标映射回原图160x160 的掩码是相对 640x640 输入图缩小的。要从掩码坐标还原到原图坐标必须经过两层映射掩码坐标 - letterbox 坐标 - 原图坐标。这里最常见的错误是直接用 640/160 4 倍放大忘了 letterbox 的 padding 偏移。假设 letterbox 时缩放比例为ratiopadding 为padX和padY那么掩码点(mx, my)对应 letterbox 图上的坐标为float x_letterbox (mx 0.5f) * (640.0f / 160.0f); float y_letterbox (my 0.5f) * (640.0f / 160.0f); // 还原到原图 float x_orig (x_letterbox - padX) / ratio; float y_orig (y_letterbox - padY) / ratio;0.5是为了让掩码像素中心对齐到输入像素中心对于大建筑影响不大但对小建筑边缘可能产生 1~2 像素偏差。如果你对边界精度要求高可以保留浮点坐标在后续轮廓检测时不要取整。OpenCvSharp 里可以用WarpAffine做整张掩码的映射比逐像素循环快得多。我一般构建一个仿射变换矩阵Mat mapMatrix new Mat(2, 3, MatType.CV_32FC1); mapMatrix.SetArray(0, 0, new float[] { scale, 0, -padX, 0, scale, -padY }); Mat mappedMask new Mat(); Cv2.WarpAffine(binary, mappedMask, mapMatrix, new OpenCvSharp.Size(srcW, srcH));其中scale 640.0f / 160.0f / ratio是综合缩放系数。注意 WarpAffine 的坐标变换是dst src * scale offset这里的 offset 是负的 padding因为掩码坐标系原点在左上角而原图在 letterbox 图中是从(padX, padY)开始的。4.3 提取建筑轮廓并计算面积拿到原图尺寸的二值掩码后一般要提取每个建筑的轮廓。建筑分割的典型应用是计算屋顶面积、生成 GIS 多边形或统计建筑数量。用FindContours提取外轮廓OpenCvSharp.Point[][] contours; HierarchyIndex[] hierarchy; Cv2.FindContours(mappedMask, out contours, out hierarchy, RetrievalModes.External, ContourApproximationModes.ApproxSimple); double totalArea 0; foreach (var contour in contours) { double area Cv2.ContourArea(contour); if (area 50) continue; // 过滤小噪点 totalArea area; // 绘制多边形 Cv2.Polylines(result, new[] { contour }, true, new Scalar(0, 255, 0), 2); }这里RetrievalModes.External只检测最外层轮廓避免嵌套关系导致同一个建筑被重复统计。面积过滤阈值 50 是经验值如果你的图像分辨率很高建筑最小面积会更大可以按(原图面积 / 10000)的倍数来设置。4.4 掩码叠加可视化最后的叠加效果常见做法是给掩码填充半透明颜色Mat overlay Mat.Zeros(src.Size(), MatType.CV_8UC3); overlay.SetTo(new Scalar(0, 0, 255), mappedMask); // 红色掩码 Cv2.AddWeighted(src, 0.7, overlay, 0.3, 0, result);如果是多建筑实例建议给每个轮廓随机生成一个颜色这样在 GIS 或遥感场景里能直观区分相邻建筑。我在源码里是用Random生成 BGR 值但要注意随机种子固定否则每次运行颜色都变。这里还要注意一个细节mappedMask是二值图值只有 0 和 255。SetTo的 mask 参数要求是单通道 8 位。如果你用的是 0/1 掩码需要先乘 255 转成合法格式否则 SetTo 不会生效。5. 推理速度优化与量化部署技巧5.1 动态形状 vs 固定形状的取舍我一开始为了适配不同尺寸的输入图导出了dynamicTrue的 onnx。结果发现 OnnxRuntime 在动态形状下每次推理都会重新做内存分配单次推理时间从 15ms 涨到 35ms而且显存碎片化严重。后来改成固定 640x640虽然小图缩放后分辨率略降但速度稳了很多。如果你的应用场景建筑分布密集可以在导出时用imgsz960或 1280推理时间会线性上升但小建筑召回率明显提升。5.2 FP16 与 INT8 量化测试我在这套 C# 项目里试过用onnxruntime.transformers.fusion做 FP16 转换也试过动态 INT8 量化。实测结果如下模型显存占用CPU 推理时间GPU 推理时间mAP50 下降FP32 原始1.8GB280ms18ms0FP160.9GB265ms9ms0.1%INT8 动态0.6GB160ms不适用1.8%INT8 量化在建筑分割场景下主要损失的是小建筑的边缘精度如果你检测的是大面积屋顶INT8 完全可用。FP16 是最稳妥的加速手段几乎没有精度损失。C# 端加载 FP16 模型时不需要改代码OnnxRuntime 会自动处理。5.3 复用 Tensor 和掩码缓冲区推理时最容易被忽视的性能瓶颈是频繁分配float[]和Mat。我建议把output0.ToArray()换成直接访问ReadOnlySpanfloat避免一次大数组拷贝。掩码生成的float[] mask可以做成成员变量每次推理前用Array.Clear重置而不是 new 新数组。用 Span 读取核心代码var output0Span output0.Buffer.Span; // 替代 ToArray() for (int a 0; a anchors; a) { float cx output0Span[0 * anchors a]; // ... }这一步减少了两次大拷贝整体推理时间能降低 10% 左右。5.4 遇到initializing... segmentation fault时的检查项如果你在 C# 里初始化 OnnxRuntime 时遇到 segmentation fault首先检查 GPU 包和 CUDA 版本。.NET 程序在 Windows 上常见的问题是Microsoft.ML.OnnxRuntime.Gpu需要 CUDA 的cudart64_*.dll在 PATH 中。另外OnnxRuntime 1.16 之后默认使用 CUDA 12如果你机器上只有 CUDA 11.8必须显式指定 1.15 版本。还有一种情况是动态模型导出时包含自定义算子OnnxRuntime 不支持导致初始化崩溃这时改用onnxsim简化模型或者重新固定动态轴导出。5.5 最后一个技巧多类别建筑分割时只取建筑类如果你用的是 COCO 预训练权重做语义分割建筑类 id 是 0正好是类别概率张量的第一个位置。在 2.1 节的切分逻辑里我特意把numClasses设为 1 来快速验证管线。但如果你有多类别场景需要同时解析多个类有一种高效方案在 NMS 前先做类别过滤只保留classId buildingId的锚点。这一步能减少 90% 以上的 NMS 计算量因为建筑检测中大部分锚点都是背景。把类别过滤放到读取输出时if (classId ! 0) continue; // 0 代表建筑注意如果你的模型是用自己的数据训练的class id 需要从训练配置里确认不要想当然认为建筑就是 0。我一般会把numClasses和classId做成配置项方便切换不同权重。本文还有配套的精品资源点击获取