做工业视觉项目时有一个问题往往比模型训练更让人头疼算法在开发电脑上跑得很好到了客户现场却不一定跑得动。前段时间接触这类工控机部署问题时最常见的情况就是相机不断采集图像C# 上位机负责显示画面、管理 PLC、记录检测结果缺陷检测模型却要和这些任务争抢 CPU。工控机没有独立显卡增加 GPU 又涉及预算、机箱空间、功耗和维护成本。这时候模型能不能在 CPU 上稳定运行就成了一个实际的工程问题。YOLO26 值得关注。Ultralytics 官方资料称在 Intel Xeon 2.00 GHz CPU 上通过 ONNX 进行 CPU 推理时YOLO26n 相比 YOLO11n 最高可快约 43%。注意这个数据是官方特定测试条件下的对比不是任意工控机都能获得的提升更不意味着所有缺陷检测任务都能达到 25 FPS。本文从 C# 工业视觉项目的部署需求出发使用 YOLO26 ONNX Runtime .NET 8 搭建一套 CPU 推理流程重点解决模型导出、图像预处理、推理、检测结果解析和性能测量等问题。先把边界说清楚本文不虚构实测成绩。25 FPS 是需要在目标工控机上验证的工程目标下面提供可运行的基线实现和可复现的测量方法而不是把未经实际测试的结果包装成实测数据。一、先选对模型和部署路线YOLO26 不是一个单独的模型文件而是一组针对不同规模、不同任务的模型。做缺陷检测时第一件事不是直接下载最大模型而是确认检测目标的尺寸、缺陷种类、漏检成本和工控机算力。YOLO26 官方支持 ONNX 导出ONNX Runtime 可以在 CPU 上执行模型不需要 NVIDIA GPU。官方导出文档也提供了 FP32、INT8 等导出选项。本文采用的部署路线是模型YOLO26n 目标检测模型。模型格式ONNX。推理引擎ONNX Runtime CPU Execution Provider。开发语言C#。运行环境.NET 8。输入尺寸640 × 640作为示例基线。推理方式单张图像、Batch 1。为什么选 ONNX因为模型训练通常在 Python 生态中完成而工控机上的设备控制、相机管理、报警、数据库和 UI 通常已经由 C# 实现。通过 ONNX 交付模型可以避免在生产程序中直接嵌入整个 Python 训练环境。这里也要明确ONNX 并不会自动让模型变快。真正的执行性能还取决于模型结构、算子实现、CPU 指令集、线程配置和数据预处理方式。二、整体架构不要让推理代码拖住上位机工业视觉程序和普通图片识别 Demo 有一个明显区别程序不仅要识别缺陷还要持续采集图像、控制设备、显示结果并且不能因为模型偶尔变慢就把整条生产线拖停。假设相机每秒产生 30 张图像而模型每秒只能处理 20 张。如果程序把每一张图都放进无界队列队列就会不断增长。过一段时间后界面虽然仍在显示检测结果但显示的可能是几秒前的图像。对于实时监控可以考虑丢弃过期帧只处理最新帧对于不能漏检的在线质检则需要保证每个工件都被处理必要时降低相机触发频率、增加算力或者采用多实例并行。这两种业务的队列策略不能混用。三、导出 ONNX模型能跑只是第一关先在安装了 Python 的开发环境中完成模型导出再把 ONNX 文件部署到 C# 工控机。安装依赖pip install ultralytics导出 YOLO26nfrom ultralytics import YOLOmodel YOLO(yolo26n.pt)model.export( formatonnx, imgsz640, batch1, devicecpu, simplifyTrue, nmsFalse)这里的yolo26n.pt是官方预训练模型名称示例。实际缺陷检测项目应当使用已经针对目标缺陷完成训练、验证的权重例如best.pt。如果训练的是自定义类别不能直接拿 COCO 预训练模型就认为它能够识别产品划痕、气泡、毛刺或裂纹。导出后通常会得到yolo26n.onnx。建议同时保留训练配置、类别名称、输入尺寸、模型版本和导出参数避免现场出现“模型文件更新了但 C# 程序还在按旧类别解释结果”的问题。nmsFalse用于选择 YOLO26 的 NMS-free 输出路径。YOLO26 的端到端检测头可以输出已经解码的候选检测结果减少在 C# 侧重复实现传统 NMS 的需要。不过不要仅凭导出参数猜输出结构。不同任务、导出版本和模型设置可能影响输入输出签名。部署前应通过 ONNX Runtime 检查模型输入输出名称、维度和数据类型并用同一张图片对照 Python 结果。四、创建 C# 项目安装运行时创建 .NET 8 控制台项目dotnet new console -n Yolo26CpuDemo cd Yolo26CpuDemo dotnet add package Microsoft.ML.OnnxRuntime dotnet add package SixLabors.ImageSharpMicrosoft.ML.OnnxRuntime提供 C# 推理 APISixLabors.ImageSharp用于读取图片、调整尺寸和访问像素。ONNX Runtime 官方 C# 文档提供了会话创建、张量输入和模型执行的基本用法。将导出的模型放在程序目录下的models/yolo26n.onnx再准备一张测试图片test.jpg。项目结构Yolo26CpuDemo/ ├── Program.cs ├── test.jpg └── models/ └── yolo26n.onnx为了避免把模型加载时间算进每次推理时间程序会在启动时创建一次InferenceSession后续重复使用。这是部署中很重要的一点模型初始化属于冷启动成本不能混进稳定运行阶段的单帧耗时。五、完整 C# 示例CPU 推理、预处理与结果解析下面的示例包含读取图片并进行 Letterbox 缩放补边。将 RGB 图像转换为float32的 NCHW 张量。使用 ONNX Runtime CPU 推理。解析 YOLO26 NMS-free 检测输出。将检测框坐标还原到原图。预热模型并统计纯推理阶段的耗时。该示例针对**目标检测任务、640 × 640 输入、单张图像、输出每个候选包含 6 个值x1,y1,x2,y2,score,classId**的 ONNX 模型。若你的模型输出形状不一致应先检查实际输出签名再调整解析器分割、姿态估计和旋转框任务不能直接套用这套解析逻辑。将以下代码放入Program.csusing System; using System.Diagnostics; using System.Linq; using Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime.Tensors; using SixLabors.ImageSharp; using SixLabors.ImageSharp.PixelFormats; const int InputSize 640; const float ConfidenceThreshold 0.25f; string modelPath Path.Combine( AppContext.BaseDirectory, models, yolo26n.onnx); string imagePath Path.Combine( AppContext.BaseDirectory, test.jpg); if (!File.Exists(modelPath)) throw new FileNotFoundException(模型文件不存在, modelPath); if (!File.Exists(imagePath)) throw new FileNotFoundException(测试图片不存在, imagePath); // CPU 推理配置。线程数需要根据目标工控机实测调整。 using var options new SessionOptions { ExecutionMode ExecutionMode.ORT_SEQUENTIAL, IntraOpNumThreads Math.Max( 1, Environment.ProcessorCount / 2) }; // 不添加 CUDA / TensorRT Provider使用 CPU 执行。 using var session new InferenceSession(modelPath, options); var inputMeta session.InputMetadata.First(); string inputName inputMeta.Key; Console.WriteLine($模型输入名称{inputName}); Console.WriteLine( $模型输入形状{string.Join(, , inputMeta.Value.Dimensions)}); foreach (var output in session.OutputMetadata) { Console.WriteLine( $模型输出{output.Key}, $Shape[{string.Join(, , output.Value.Dimensions)}]); } // 预处理一次推理基准不包含图片解码和张量构造。 using var image Image.LoadRgb24(imagePath); float[] inputData Preprocess( image, InputSize, out LetterboxInfo letterbox); var inputTensor new DenseTensorfloat( inputData, new[] { 1, 3, InputSize, InputSize }); var inputs new[] { NamedOnnxValue.CreateFromTensor(inputName, inputTensor) }; // 预热让运行时完成首次执行及相关初始化。 for (int i 0; i 5; i) { using var warmup session.Run(inputs); } // 正式测量纯推理耗时。 const int Iterations 100; var timings new double[Iterations]; for (int i 0; i Iterations; i) { var sw Stopwatch.StartNew(); using var results session.Run(inputs); // 在计时范围内取出输出确保本轮结果已经可访问。 var output results.First().AsTensorfloat(); _ output.Length; sw.Stop(); timings[i] sw.Elapsed.TotalMilliseconds; } Array.Sort(timings); double avg timings.Average(); double p50 Percentile(timings, 0.50); double p95 Percentile(timings, 0.95); Console.WriteLine(); Console.WriteLine($推理平均耗时{avg:F2} ms); Console.WriteLine($推理 P50{p50:F2} ms); Console.WriteLine($推理 P95{p95:F2} ms); Console.WriteLine( $纯推理吞吐估算{1000.0 / avg:F2} FPS); using (var finalResults session.Run(inputs)) { var output finalResults.First().AsTensorfloat(); var detections ParseDetections( output, letterbox, image.Width, image.Height, ConfidenceThreshold); Console.WriteLine(); Console.WriteLine($检测到 {detections.Count} 个候选目标); foreach (var d in detections) { Console.WriteLine( $Class{d.ClassId}, $Score{d.Score:F3}, $Box({d.X1:F1},{d.Y1:F1}, ${d.X2:F1},{d.Y2:F1})); } } static float[] Preprocess( ImageRgb24 image, int size, out LetterboxInfo info) { int originalWidth image.Width; int originalHeight image.Height; float scale Math.Min( size / (float)originalWidth, size / (float)originalHeight); int resizedWidth Math.Clamp( (int)Math.Round(originalWidth * scale), 1, size); int resizedHeight Math.Clamp( (int)Math.Round(originalHeight * scale), 1, size); int left (size - resizedWidth) / 2; int top (size - resizedHeight) / 2; using var resized image.Clone( ctx ctx.Resize(resizedWidth, resizedHeight)); // 与常见 YOLO Letterbox 预处理保持一致的灰色填充。 using var canvas new ImageRgb24( size, size, new Rgb24(114, 114, 114)); resized.ProcessPixelRows(src { canvas.ProcessPixelRows(dst { for (int y 0; y resizedHeight; y) { src.GetRowSpan(y).CopyTo( dst.GetRowSpan(y top) .Slice(left, resizedWidth)); } }); }); // NCHW先写 R 平面再写 G、B 平面。 // 像素值归一化到 [0, 1]。 var data new float[3 * size * size]; int plane size * size; canvas.ProcessPixelRows(accessor { for (int y 0; y size; y) { var row accessor.GetRowSpan(y); for (int x 0; x size; x) { Rgb24 pixel row[x]; int index y * size x; data[index] pixel.R / 255.0f; data[plane index] pixel.G / 255.0f; data[2 * plane index] pixel.B / 255.0f; } } }); info new LetterboxInfo( resizedWidth / (float)originalWidth, resizedHeight / (float)originalHeight, left, top); return data; } static System.Collections.Generic.ListDetection ParseDetections( System.Numerics.Tensors.Tensorfloat output, LetterboxInfo info, int originalWidth, int originalHeight, float threshold) { // 本示例预期每个候选有 6 个值 // x1, y1, x2, y2, confidence, classId。 // 常见端到端输出形状为 [1, N, 6]。 if (output.Length % 6 ! 0) { throw new InvalidOperationException( $输出元素数量为 {output.Length} 无法按每个候选6个值解析。请检查模型输出签名。); } var values output.ToArray(); var detections new System.Collections.Generic.ListDetection(); for (int i 0; i values.Length; i 6) { float score values[i 4]; if (score threshold) continue; float x1 (values[i] - info.Left) / info.ScaleX; float y1 (values[i 1] - info.Top) / info.ScaleY; float x2 (values[i 2] - info.Left) / info.ScaleX; float y2 (values[i 3] - info.Top) / info.ScaleY; x1 Math.Clamp(x1, 0, originalWidth); y1 Math.Clamp(y1, 0, originalHeight); x2 Math.Clamp(x2, 0, originalWidth); y2 Math.Clamp(y2, 0, originalHeight); if (x2 x1 || y2 y1) continue; detections.Add(new Detection( x1, y1, x2, y2, score, (int)values[i 5])); } return detections; } static double Percentile(double[] sorted, double p) { int index (int)Math.Ceiling(p * sorted.Length) - 1; index Math.Clamp(index, 0, sorted.Length - 1); return sorted[index]; } sealed record LetterboxInfo( float ScaleX, float ScaleY, int Left, int Top); sealed record Detection( float X1, float Y1, float X2, float Y2, float Score, int ClassId);编译运行dotnet run -c Release注意两点。第一代码测量的是推理阶段不包括相机采集、图片解码、预处理和 UI 绘制因此输出的 FPS 是纯推理吞吐估算不能直接当成整条产线的检测节拍。第二YOLO26 的导出结果应以实际模型为准。运行程序时先检查输出名称和形状如果模型输出并非每个候选 6 个值就必须修改ParseDetections不能强行按这个格式解释。六、为什么有的 C# 示例能跑换成真实模型却报错实际部署中模型加载成功只是第一步。最容易出问题的通常是输入张量、输出格式和坐标还原。先补充一个代码细节上面ParseDetections的参数类型应写为Tensorfloat使用文件顶部已经引入的Microsoft.ML.OnnxRuntime.Tensors命名空间而不是System.Numerics.Tensors.Tensorfloat。对应的方法签名应为static System.Collections.Generic.ListDetection ParseDetections( Tensorfloat output, LetterboxInfo info, int originalWidth, int originalHeight, float threshold)1. NCHW 和 NHWC 搞反了模型通常要求的输入形状是[Batch, Channel, Height, Width] [1, 3, 640, 640]也就是 NCHW。但相机和图像库提供的像素一般是按行排列每个像素包含 RGB 或 BGR 通道。如果直接把图像内存复制给模型往往会得到错误的检测结果。代码中的Preprocess通过三个连续的平面存储 R、G、B并将像素值归一化到[0,1]。这符合本文设定的模型输入契约但实际使用前仍应与训练和导出时的预处理保持一致。如果模型要求 BGR、均值方差归一化或者使用其他输入尺寸就必须按模型要求修改预处理。2. Letterbox 的补边没有还原假设原图尺寸为 1920 × 1080模型输入是 640 × 640。按比例缩放后图像大约变成 640 × 360上下还需要补边。模型输出的是补边后图像中的坐标而业务系统通常需要原始相机图像的坐标。坐标还原需要同时考虑缩放比例和补边偏移如果漏掉补边偏移检测框就会整体偏移如果使用错误的缩放比例图像边缘的缺陷框可能明显错位。工业视觉项目里这种错误比推理慢几毫秒更危险模型可能确实识别到了缺陷但 PLC 联动使用的却是错误位置。3. 把模型输出误当成传统 YOLO 原始输出传统 YOLO 模型经常需要解析大量候选框再执行置信度筛选和 NMS。YOLO26 的端到端检测路径可以减少这部分后处理工作。但不能因此认为所有 YOLO 模型都使用相同输出结构。本文代码针对的是每个候选包含x1、y1、x2、y2、score、classId的输出。若实际 ONNX 文件输出的是原始特征图或其他维度解析器就必须针对实际模型修改。建议部署前记录输入名称、类型和维度。输出名称、类型和维度。模型类别数量及类别名称。置信度阈值。坐标格式和输入预处理方式。这些信息最好和模型文件一起版本管理。七、性能优化不要一上来就把线程数拉满代码能运行后下一步才是性能优化。我的建议是先测量再改动不要同时调整模型、线程数、图像尺寸和量化精度否则即使 FPS 变高也很难知道究竟是哪项改动起了作用。1. 先理解数据处理的时间复杂度对于 640 × 640 的输入图像预处理需要访问这还没有计算原图缓冲区、缩放图像、ONNX 输出、相机 SDK 缓冲区和 UI 使用的内存。预处理的时间复杂度是 \(O(HW)\)因为每个像素都要读取、转换和写入。检测结果解析则是 \(O(N)\)其中 \(N\) 是输出候选数量。对于端到端检测输出候选数量通常远小于输入像素数量因此在很多场景中图像预处理和模型推理比简单的结果遍历更值得关注。本文的代码还使用了ToArray()会把输出张量复制到新的托管数组。它方便理解但在高频运行时可能增加内存分配和垃圾回收压力。若输出结构固定可以进一步改为直接访问张量减少不必要的复制。2. 为什么推理会占用这么多 CPU卷积网络的主要计算通常集中在卷积、矩阵乘法和相关张量运算上。CPU 执行这些操作时会受到以下因素影响可用物理核心数量。SIMD 指令支持。内存带宽与缓存命中率。ONNX Runtime 的图优化和算子实现。推理线程与上位机其他线程之间的竞争。ONNX Runtime 会根据模型和运行环境执行相应的优化但并不是线程越多就越快。官方文档也提供了线程管理与性能诊断相关说明。例如工控机有 8 个逻辑处理器并不意味着将推理线程数设置为 8 就一定最优。如果相机采集、PLC 通信、UI 绘制也需要 CPU过多的推理线程可能导致上下文切换增加反而使整机响应变差。建议在目标设备上至少对比以下配置测试项配置示例推理线程1、2、4模型输入640 × 640Batch1执行后端CPU运行模式Release预热正式计时前执行统计指标平均值、P50、P95、CPU 占用这里列出的是测试方案不是实测结果。最终线程配置应根据目标 CPU 和整机负载决定。3. 有界队列比无界队列更适合实时检测在相机采集与模型推理解耦时建议使用有界队列。其基本原理很简单队列最多保存固定数量的待处理图像。生产者向队列添加帧消费者取帧推理。对于固定容量的环形缓冲区入队和出队通常可以做到 \(O(1)\)不需要每次移动整个队列的数据。但具体的丢帧策略取决于业务在线质检不能随意丢弃工件图像应保证每个工件都有检测结果。实时监控允许丢弃过期帧时可以采用只保留最新帧的策略。连续高速检测如果模型长期处理不过来就需要降低输入速率、提高模型吞吐或扩充算力。仅仅增加队列容量不会提高模型的处理速度只会延迟队列被填满的时间。4. 输入尺寸不是越小越好将输入从 640 × 640 改为 512 × 512像素数量会减少约 36%。这通常能降低部分预处理和模型计算成本但并不意味着整体推理耗时也一定下降 36%。更重要的是缩小输入会让微小缺陷变得更难识别。对于表面划痕、针孔、细小裂纹等缺陷输入尺寸与模型精度之间可能存在明显权衡。因此建议把 512、640 以及更高分辨率作为独立实验组同时比较单帧推理耗时。整体吞吐量。缺陷召回率。误检率。小目标检测效果。工业检测不能只看 FPS。一个跑得很快但漏掉关键缺陷的模型没有实际生产价值。5. INT8 量化值得测试但不能直接认定一定更快YOLO26 的 ONNX 导出支持 INT8 量化选项。量化可以减少部分模型权重和计算所需的资源但最终收益取决于 CPU 指令集、量化算子支持、模型结构和量化方案。尤其要注意量化之后不能只比较推理速度还要重新验证缺陷召回率、误检率以及不同光照条件下的稳定性。对于漏检代价很高的项目建议先建立 FP32 基线再评估 INT8 的速度收益与精度损失。不要为了达到某个 FPS 数字就直接把精度风险转移给生产线。八、25 FPS 到底意味着什么先把账算清楚但这里有个容易忽略的问题40 ms 是整条处理链路的预算不是单纯的模型推理预算。例如以下数字只是用于说明如何分配时间并非实测结果处理阶段假设耗时图像获取与解码4 ms缩放与张量转换6 ms模型推理24 ms结果解析与业务判定2 ms其他开销4 ms合计40 ms这个预算意味着假设其他阶段的耗时保持不变模型推理阶段如果从 24 ms 降到 18 ms整条处理链路才会从 40 ms 降到 34 ms对应吞吐上限约为 29.4 FPS。因此单独优化模型并不一定能按相同比例提升整机 FPS。如何进行一次可信的现场测试建议把测试分成三个层次。第一层纯推理测试固定输入张量预热后连续运行记录平均值、P50、P95。用于比较线程数、模型格式和量化方案。第二层完整图像测试包含图像解码、预处理、推理和结果解析使用真实相机分辨率与实际缺陷图片。第三层整机压力测试同时运行相机采集、PLC 通信、UI、日志与数据库观察长时间运行的吞吐、P95 延迟、CPU 占用和内存增长。真正决定能不能上线的是第三层。如果纯推理达到 30 FPS但完整图像链路只有 22 FPS就不能宣称系统已经实现稳定的 25 FPS。还需要检查采集队列是否持续积压以及高负载下是否存在长尾延迟。另外测试时最好使用与现场相同的 CPU 型号、内存配置、电源策略和软件环境并记录模型版本、输入尺寸、线程数和预热次数。对于在线质检还应该验证每个工件的检测结果是否能在工艺允许的时间内返回。平均 FPS 达标并不代表每一帧都能按时完成。九、把推理模块做成独立服务才适合长期维护如果只是验证模型控制台程序已经够用。但在正式的 C# 上位机项目中我更倾向于将推理模块独立封装。例如public interface IDefectDetector : IDisposable { IReadOnlyListDetection Detect( ImageRgb24 image); }这里的接口用于说明架构边界实际项目可以根据异步采集、超时和取消需求进一步调整。上层业务只负责提交图像和消费检测结果不需要知道内部使用 YOLO26、ONNX Runtime还是未来替换成 OpenVINO。模块内部则负责模型加载和生命周期管理。输入预处理与输出解析。推理耗时统计。错误日志与模型版本管理。结果坐标还原。并发控制和资源释放。还有一点值得注意ONNX Runtime 的会话通常应该复用而不是每处理一张图片就重新创建一次。反复加载模型会引入额外初始化成本并造成不必要的资源开销。如果需要多个推理工作线程也应先验证所用运行时版本与接口的并发使用方式再决定采用共享会话还是独立会话。最后缺陷检测结果与 PLC 控制逻辑之间最好再增加一层业务判定。模型输出的是检测候选不应该直接在推理代码中执行设备停机、剔除或复位。对于关键控制动作需要明确检测结果的有效性、超时策略和设备安全状态。十、最后回到标题中的两个数字“CPU 提速 43%”有官方依据但必须准确理解它的含义Ultralytics 给出的数据是特定测试环境下YOLO26n 相比 YOLO11n 的 CPU ONNX 推理速度提升测试环境为 Intel Xeon 2.00 GHz CPU。它不能直接推导出你的工控机也会提升 43%。“25 FPS”则是另一个问题。它需要由目标设备上的实际测试来证明尤其要区分纯推理 FPS 和包含采集、预处理、后处理及业务处理的端到端 FPS。对于没有独立 GPU 的工业工控机我建议按照以下顺序推进用 YOLO26n 和 ONNX Runtime 建立 CPU 基线。验证模型输出、坐标映射和缺陷检测精度。分别测量预处理、推理与后处理的耗时。调整输入尺寸、线程数和量化方案。在相机、PLC 和 UI 同时运行的条件下进行长时间压力测试。如果最终发现模型推理已经足够快但完整流程仍达不到生产节拍就应该继续优化数据流、图像处理和任务调度而不是盲目更换模型。工业视觉部署真正要追求的不是某一次测试跑出一个漂亮的 FPS而是在目标工控机上持续、稳定、可复现地完成每一次缺陷检测。参考资料Ultralytics YOLO26 ONNX 导出文档Ultralytics YOLO26 模型说明ONNX Runtime 官方 C# 入门文档以上资料可用于核对模型导出选项、CPU 性能说明和 C# 推理 API。实际部署时建议以所用模型文件、ONNX Runtime 版本及目标工控机的测试结果为准。