☰
C#集成YOLO目标检测:ONNX Runtime与OpenCvSharp实战指南
2026/10/19 3:02:22 网站建设 项目流程

最近做桌面端工具,需要把目标检测能力内嵌到一个C#应用程序里,绕了一圈最终选定了 Microsoft.ML.OnnxRuntime + OpenCvSharp + YOLO26 这套组合。整个链路跑通之后回头再看,其实踩坑点很集中,核心就是预处理、输出解析和后处理这三个环节,而调用ONNX Runtime本身是非常稳定的。这篇文章把从环境配置到检测结果画框的完整过程拆开讲一遍,给后面要碰C# + ONNX目标检测的读者一个可以直接抄的作业,也顺手把那些训练文档里不会写的坑都列出来。

1. 整体设计思路:为什么选这套方案

目标检测的落地方式有很多,Python下面用PyTorch直接跑、TensorRT加速、OpenCV DNN模块、ONNX Runtime,各有各的适用场景。但放在C#桌面应用里,可选集一下就缩小了。

先说要解决什么问题:程序要在本机加载一个训练好的目标检测模型,对摄像头采集或本地图片实时输出目标的类别和位置,并且要能方便地集成到已有的C#业务代码中。这个需求意味着不能开一个Python子进程来跑推理,那样进程通信和模型热加载都很难受,也不能只按某个特定硬件优化,因为要分发到不同配置的机器上。

Microsoft.ML.OnnxRuntime 成了最合理的中间层。它是一个跨平台的推理引擎,支持CPU、GPU,模型格式是业界通用的ONNX,几乎所有主流深度学习框架训练的模型都能导出来。C#调用它没有任何runtime之外的额外负担,NuGet包一引,代码直接实例化一个 InferenceSession 就能跑。相比直接用 OpenCV DNN 模块,OnnxRuntime 对YOLO系列输出的兼容性和推理性能都要更好,尤其是对动态输入和半精度模型的优化更成熟。

选 OpenCvSharp 做图像处理是顺其自然的事:C#生态里原生图像处理能力偏弱,而 OpenCvSharp 几乎是OpenCV C++接口的直接镜像,Mat 结构、Rect、Scalar、绘图方法都非常直观,和YOLO系列训练时用的OpenCV操作一脉相承,不需要额外学一套图像库的API。而且OpenCvSharp有现成的BGR/RGB转换、resize、copyMakeBorder,这些都是YOLO预处理和后处理画框必须的能力。

最后是模型本身。YOLO26 是YOLO系列较新的版本(标题里这个叫法也可以指代你手上实际导出的模型文件),但只要是YOLO家族导出的ONNX模型,调用方式大同小异,区别主要在输出张量的结构上。这个点我后面会单独用一节讲,也是最容易翻车的地方。

综合下来这套方案的优势很直接:跨平台、包管理方便、性能足够,而且整个链路没有第三方商业组件,成本可控,适合直接嵌进业务系统。

2. 环境准备与依赖配置

先在项目里把三样东西装齐。我用的是.NET 6,但.NET Core 3.1以上都没问题,控制台应用、WinForms、WPF都适用。

dotnet new console -n YoloDemo cd YoloDemo dotnet add package Microsoft.ML.OnnxRuntime dotnet add package OpenCvSharp4

这里有个必须提醒的坑:OpenCvSharp4 这个包本身只包含托管代码,实际的原生DLL需要通过运行时包带进来。Windows平台要额外加一个:

dotnet add package OpenCvSharp4.runtime.win

不加这个包,程序运行到 Cv2.ImRead 那一行就会抛 DllNotFoundException,报找不到 OpenCvSharpExtern 之类的错误。这个问题极常见,几乎每个第一次用OpenCvSharp的人都会遇到。Linux和macOS对应的则是 OpenCvSharp4.runtime.ubuntu 或 OpenCvSharp4.runtime.osx,按目标平台选择。

OnnxRuntime 默认装的是CPU版本,如果机器有NVIDIA显卡,可以换成 Microsoft.ML.OnnxRuntime.GPU,需要CUDA环境配合。不过初学阶段先跑通CPU版本,后面再上GPU也不迟。

依赖装好后先写一段验证代码确认环境没问题:

using System; using System.Linq; using Microsoft.ML.OnnxRuntime; using OpenCvSharp; Console.WriteLine("ONNX Runtime Version: " + OrtEnv.Instance().Version); Console.WriteLine("OpenCvSharp Version: " + Cv2.GetVersionString()); using var session = new InferenceSession("模型文件路径"); Console.WriteLine("Session Created. Inputs: " + string.Join(", ", session.InputNames));

这里会用到 OrtEnv.Instance(),这是OnnxRuntime在C#侧的一个环境实例,一般全局只有一个就够了,不用重复创建。如果这几行能顺利打印版本信息,说明原生依赖都加载成功,可以进入正题。

整个项目的文件组织,我习惯单独放一个 Models 目录存ONNX文件,一个 Images 目录放测试图片,代码里统一用相对路径拼接,避免不同环境执行时路径对不上。

3. 模型准备:导出格式与放置方式

你手上可能已经有一个 .onnx 文件,也可能需要从常用的训练框架里把模型导出来。不管哪种来源,落到ONNX这一步之后,模型结构是固定的:输入是一个四维张量 [batch, channels, height, width],最常见的配置是 [1, 3, 640, 640];输出则分两类情况,这是后面解析的关键。

旧版YOLO(比如v5的某些导出方式)输出形状是 [1, N, 4+ClassCount],其中 N 是所有候选框的总数,比如640×640输入下通常是25200(三个尺度的预测叠加),数据排列是行优先,每一行是 [cx, cy, w, h, cls1, cls2, ...] 或者 [cx, cy, w, h, obj_conf, cls...],取决于导出时的配置。

新版YOLO(v8之后默认导出方式)输出形状是 [1, 4+ClassCount, N],比如类别数是80时就是 [1, 84, 8400],排布是每个通道放一类信息,前4行是坐标,后面是各类别的置信度,按列索引查候选框。这就是为什么网上很多教程里解析代码完全不同,其实都是对的,只是面对的输出格式不同。

如果你拿到的是带后处理节点的ONNX模型,输出可能已经转换成 [1, N, 4+ClassCount] 且经过了NMS,这种最省事,直接用第一个输出就行。但训练框架里直接导出的通常是裸输出,需要自己做NMS,这也是这篇文章核心要讲的。

建议先把模型放到项目目录下,再用一个简单方法确认它的输入输出张量结构,避免猜:

using var session = new InferenceSession("Models/yolo26.onnx"); foreach (var (key, meta) in session.InputMetadata) { Console.WriteLine($"Input: {key}, Shape: [{string.Join(",", meta.Dimensions)}], Type: {meta.ElementType}"); } foreach (var (key, meta) in session.OutputMetadata) { Console.WriteLine($"Output: {key}, Shape: [{string.Join(",", meta.Dimensions)}], Type: {meta.ElementType}"); }

ONNX Runtime在桌面平台一般是动态形状,打印出来可能会有 -1 占位符,比如 [-1, 3, -1, -1],这没关系,运行时输入什么尺寸就会推导什么尺寸。建议做产品时固定输入尺寸,避免动态shape带来的推理性能损耗。

把模型放Models目录后,记得在csproj里把它复制到输出目录:

<ItemGroup> <None Update="Models/**/*"> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> </None> </ItemGroup>

否则发布后模型文件不会跟着走,运行时找不到模型又是一顿排查。

4. 图像预处理:LetterBox与张量转换

YOLO推理前要做的预处理看起来很基础,其实就是BGR转RGB、缩放、归一化、HWC转CHW这么几步,但里面最容易出错的是缩放方式。直接使用 Cv2.Resize 把图片强行压成640×640会破坏原始宽高比,目标形状会变形,检测框坐标再还原回去时偏差很大。正确做法是LetterBox,也就是等比缩放加上灰边填充。

LetterBox的思路是先算原图宽高到目标尺寸的缩放比例,取一个较小的比例保证图片完全放进640×640内,然后用 padding 补齐剩余空间。代码实现:

private static Mat LetterBox(Mat src, int targetSize, out float scale, out int dw, out int dh) { var srcW = src.Width; var srcH = src.Height; scale = Math.Min((float)targetSize / srcW, (float)targetSize / srcH); var newW = (int)Math.Round(srcW * scale); var newH = (int)Math.Round(srcH * scale); dw = (targetSize - newW) / 2; dh = (targetSize - newH) / 2; var resized = new Mat(); Cv2.Resize(src, resized, new Size(newW, newH), 0, 0, InterpolationFlags.Linear); var canvas = new Mat(new Size(targetSize, targetSize), MatType.CV_8UC3, Scalar.All(114)); resized.CopyTo(canvas[new Rect(dw, dh, newW, newH)]); return canvas; }

这里的 Scalar.All(114) 是YOLO官方训练时用的fill值,虽然不是必须完全一致,但保持一致能让推理结果更接近训练时的统计分布。 dw、dh 这份数据后面还原检测框坐标时必须用到,必须从预处理方法里带出来。

拿到了640×640的Mat,下一步要把它转成模型能吃的张量。OnnxRuntime的输入一般是 DenseTensor ,形状是 [1, 3, 640, 640]。OpenCvSharp的Mat在内存里是HWC排布(高度、宽度、通道),而模型要求CHW排布(通道、高度、宽度),所以要做一次数据重排。

private static Tensor<float> PreprocessToTensor(Mat rgbMat, int targetSize) { var tensor = new DenseTensor<float>(new[] { 1, 3, targetSize, targetSize }); var channels = new byte[3][]; for (int c = 0; c < 3; c++) { Mat channel = rgbMat.ExtractChannel(c); channels[c] = new byte[targetSize * targetSize]; Marshal.Copy(channel.Data, channels[c], 0, targetSize * targetSize); } for (int y = 0; y < targetSize; y++) { for (int x = 0; x < targetSize; x++) { tensor[0, 0, y, x] = channels[0][y * targetSize + x] / 255f; tensor[0, 1, y, x] = channels[1][y * targetSize + x] / 255f; tensor[0, 2, y, x] = channels[2][y * targetSize + x] / 255f; } } return tensor; }

这里每次调用 ExtractChannel 会分配临时Mat,性能不是最优,但理解起来非常直观,适合第一版跑通。后面要优化可以改成直接读取Mat连续内存再手工索引重排,能省掉大量拷贝。

BGR转RGB这一步放在哪做都可以,一般我习惯放在LetterBox之后、转张量之前,Cv2.CvtColor(mat, mat, ColorConversionCodes.BGR2RGB)。注意OpenCvSharp里的 Mat 通道顺序是BGR,但YOLO训练时输入的是RGB格式,如果不转颜色通道,检测结果会明显变差,尤其对色彩敏感的类别会更明显。

整个过程有一个容易被忽略的点:图像像素取值范围。模型训练时一般归一化到0到1,如果直接喂0到255的值,输出置信度会整体异常。上面代码里已经除以255,千万别漏。

5. 推理:创建Session与执行Run

推理部分反而是整条链路里代码最少的。创建会话只需要一个文件路径,但有几个会话配置建议按场景设置。

private readonly InferenceSession _session; public YoloDetector(string modelPath) { var options = new SessionOptions { GraphOptimizationLevel = GraphOptimizationLevel.ORT_ENABLE_ALL, EnableMemoryPattern = true, EnableCpuMemArena = true }; _session = new InferenceSession(modelPath, options); }

GraphOptimizationLevel 建议直接开到 ORT_ENABLE_ALL,让ONNX Runtime对计算图做尽可能的优化。因为YOLO模型结构并不复杂,优化耗时可以忽略,但推理延迟能降不少。

执行推理时,先构造输入张量,然后调用 Run。OnnxRuntime在较新版本里推荐直接传 DenseTensor:

public List<Detection> Detect(Mat image) { var targetSize = 640; var letterboxed = LetterBox(image, targetSize, out var ratio, out var dw, out var dh); Cv2.CvtColor(letterboxed, letterboxed, ColorConversionCodes.BGR2RGB); var tensor = PreprocessToTensor(letterboxed, targetSize); var inputs = new List<NamedOnnxValue> { NamedOnnxValue.CreateFromTensor(_session.InputNames[0], tensor) }; using var results = _session.Run(inputs); // 解析 results ... }

有一个经验之谈:理想情况下结果对象应该用using及时释放,尤其做视频流连续推理时,不释放会导致内存只增不减。Run之后拿里面的输出张量,用AsTensor ()取出。

var output = results.First().AsTensor<float>(); var shape = output.Dimensions;

这里输出维度信息决定了怎么解析。上面的代码假设输入名是 _session.InputNames[0],这个用法没问题,但如果模型有多个输入(比如部分YOLO版本接受第二个原始尺寸参数),就不能只取第一个。稳妥做法是确认模型输入名,常见的是 images 或 input,不猜,直接打印配置里确认。

推理本身还有个小技巧:如果要在Web服务或UI线程里频繁调用,建议复用同一个InferenceSession实例,不要每次推理都重新加载模型。InferenceSession是线程安全的,同一个实例并发调用多个Run是允许的,但要注意每路推理拿到的是独立的结果对象。

6. 后处理核心:输出解析与坐标还原

后处理是整个流程里最需要细心、最容易写错的部分。先说输出解析,针对前面说的两种格式分别处理。

如果输出形状是 [1, N, 4+C],即新旧YOLO的类别都取威胁的那种行式结构,解析时直接遍历每个候选框,对类别部分取最大值:

var data = output.ToArray(); int rows = shape[1]; int cols = shape[2]; for (int i = 0; i < rows; i++) { var offset = i * cols; float cx = data[offset + 0]; float cy = data[offset + 1]; float w = data[offset + 2]; float h = data[offset + 3]; float maxScore = 0f; int classId = -1; for (int j = 4; j < cols; j++) { if (data[offset + j] > maxScore) { maxScore = data[offset + j]; classId = j - 4; } } if (maxScore > confidenceThreshold) { detections.Add(new Detection(...)); } }

如果输出形状是 [1, C, N],也就是通道在前,坐标和805类别置信度在通道维上,写法要反过来:

int boxCount = shape[2]; bool objectnessIncluded = cols == 85; // 取决于导出配置 for (int i = 0; i < boxCount; i++) { float cx = data[0 * boxCount + i]; float cy = data[1 * boxCount + i]; float w = data[2 * boxCount + i]; float h = data[3 * boxCount + i]; float maxScore = 0f; int classId = -1; for (int c = 4; c < data.Length / boxCount; c++) { float score = data[c * boxCount + i]; if (score > maxScore) { maxScore = score; classId = c - 4; } } }

识别出格式的类名很容易:行优先的输出 first.Dimensions.Length 通常是3且Dimensions[2]比较大(几十到几百);通道优先的输出 Dimensions[1] 等于类别数加4(比如84或85),Dimensions[2]是候选框数量(如8400)。

坐标还原这一步,也要分清楚:从模型出来的 cx, cy, w, h 是针对640×640输入图像的坐标,不是原图的坐标。要映射回原图,先做一步框坐标变换:

float x1 = cx - w / 2f; float y1 = cy - h / 2f; float x2 = cx + w / 2f; float y2 = cy + h / 2f;

然后按 LetterBox 的逆变换写回原图坐标:

x1 = (x1 - dw) / ratio; y1 = (y1 - dh) / ratio; x2 = (x2 - dw) / ratio; y2 = (y2 - dh) / ratio;

这一步还有个容易被精度坑的情况:dw 和 dh 在 LetterBox 里是整数,但逆变换时最好保持为 float,否则计算偏移会丢掉像素精度。尤其是画框在原图上偏移几个像素,一眼就能看出来不对,但排查起来又特别耗时间。

最后,把候选框格式从 xyxy 统一存到 Detection 对象里,顺便把置信度和类别ID带上,准备做NMS。

7. NMS实现与调试经验

模型一次性输出的候选框数量很多,默认置信度阈值筛完还有大量重叠框,所以需要非极大值抑制(NMS)。NMS在OpenCV里其实提供了现成的接口,但 OpenCvSharp 封装后需要把框转成 Rect 或 Rect2f,反而变量多。我建议直接手写一个简单版本,完全可控、便于调试。

NMS的基本逻辑是按置信度从高到低排序,依次选择最高分框,把和它重叠面积超过阈值的其它框丢弃:

public List<Detection> Nms(List<Detection> detections, float iouThreshold = 0.45f) { var sorted = detections .Select((d, index) => new { Detection = d, Index = index }) .OrderByDescending(x => x.Detection.Confidence) .ToList(); var kept = new List<Detection>(); var suppressed = new bool[sorted.Count]; for (int i = 0; i < sorted.Count; i++) { if (suppressed[i]) continue; var keep = sorted[i].Detection; kept.Add(keep); for (int j = i + 1; j < sorted.Count; j++) { if (suppressed[j]) continue; if (IoU(keep, sorted[j].Detection) > iouThreshold) { suppressed[j] = true; } } } return kept; }

IoU计算用的是标准的交集除以并集:

private float IoU(Detection a, Detection b) { float x1 = Math.Max(a.X1, b.X1); float y1 = Math.Max(a.Y1, b.Y1); float x2 = Math.Min(a.X2, b.X2); float y2 = Math.Min(a.Y2, b.Y2); float interW = Math.Max(0, x2 - x1); float interH = Math.Max(0, y2 - y1); float interArea = interW * interH; float unionArea = (a.X2 - a.X1) * (a.Y2 - a.Y1) + (b.X2 - b.X1) * (b.Y2 - b.Y1) - interArea; return interArea / unionArea; }

写NMS时最容易忽略的一点是:是否按类别分开做。如果所有类别放在一起做NMS,不同类别的目标重叠时可能会把其中一个错删。正确的经验是按类别ID分组,每组内做NMS,然后合并结果。这样同类目标间抑制,不同类别目标互不干扰。

还有一个小优化:在NMS之前把置信度已经低于阈值的候选框先过滤掉,避免大量低分框参与排序和计算,这个能显著降低耗时。

调试阶段可以在NMS之前加一个开关,把过滤前后的框数量打印出来,看看置信度阈值设置是否合理。不同模型的最优阈值差异很大,换模型时不排查阈值直接沿用旧配置,容易出现要么漏检要么全是框的情况。

8. 完整Demo代码整合

把上面这些串起来,做一个 YoloDetector 类,对外只暴露一个 Detect 方法,输入 Mat、输出检测结果列表。这样业务代码调用非常干净。

using System; using System.Collections.Generic; using System.Linq; using System.Runtime.InteropServices; using Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime.Tensors; using OpenCvSharp; public class Detection { public float X1 { get; set; } public float Y1 { get; set; } public float X2 { get; set; } public float Y2 { get; set; } public float Confidence { get; set; } public int ClassId { get; set; } } public class YoloDetector : IDisposable { private readonly InferenceSession _session; private readonly float _confidenceThreshold; private readonly float _iouThreshold; private readonly int _targetSize; public YoloDetector(string modelPath, float confidenceThreshold = 0.25f, float iouThreshold = 0.45f, int targetSize = 640) { _session = new InferenceSession(modelPath); _confidenceThreshold = confidenceThreshold; _iouThreshold = iouThreshold; _targetSize = targetSize; } public List<Detection> Detect(Mat image) { var letterboxed = LetterBox(image, _targetSize, out var ratio, out var dw, out var dh); Cv2.CvtColor(letterboxed, letterboxed, ColorConversionCodes.BGR2RGB); var tensor = PreprocessToTensor(letterboxed, _targetSize); var inputs = new List<NamedOnnxValue> { NamedOnnxValue.CreateFromTensor(_session.InputNames[0], tensor) }; using var results = _session.Run(inputs); var output = results.First().AsTensor<float>(); var shape = output.Dimensions; var boxes = ParseOutput(output, shape, ratio, dw, dh); return Nms(boxes, _iouThreshold); } // 这里放 LetterBox、PreprocessToTensor、ParseOutput、Nms 的具体实现 // 完整代码建议放一个单独的 YoloDetector.cs 里 public void Dispose() { _session?.Dispose(); } }

主程序里调用,并画框保存结果:

using var detector = new YoloDetector("Models/yolo26.onnx"); using var image = Cv2.ImRead("Images/test.jpg"); var detections = detector.Detect(image); foreach (var d in detections) { var rect = new Rect( (int)Math.Round(d.X1), (int)Math.Round(d.Y1), (int)Math.Round(d.X2 - d.X1), (int)Math.Round(d.Y2 - d.Y1)); Cv2.Rectangle(image, rect, Scalar.Red, 2); Cv2.PutText(image, d.ClassId.ToString(), new Point(rect.X, Math.Max(0, rect.Y - 5)), HersheyFonts.HersheySimplex, 0.6, Scalar.Green, 1); } Cv2.ImWrite("Images/result.jpg", image); Console.WriteLine($"检测完成,保留 {detections.Count} 个目标");

画框和文字时别忽略 Y 坐标小于0 的情况,检测框贴着图片上边缘时,文字会写到图片外面,虽然OpenCV不报错但会明显缺字,做产品时很影响观感。

9. 常见问题与排查技巧实录

把这段时间踩过的坑集中整理成表,基本覆盖了C# + OnnxRuntime + YOLO常见的问题,按出现频率排序:

症状原因解决方式
运行时报 DllNotFoundExceptionOpenCvSharp原生DLL缺失安装对应的 OpenCvSharp4.runtime.win 等运行时包
推理输出全是0置信度输入张量没归一化或颜色通道没转RGB确认除以255,确认BGR转RGB
检测框位置严重偏移LetterBox的 dw/dh/ratio 没有正确传递或坐标还原遗漏检查画框用的是否还原到原图的坐标
输出张量解析索引越界模型输出格式判断错误(行式/通道式混淆)打印shape,按 Dimensions 判断格式后选择解析代码
大图输入特别慢动态输入导致推理内部重新申请内存固定输入尺寸,或预处理直接LetterBox到640
内存持续上涨没有释放 Run 返回的结果对象改用 using 包住 results,及时释放
换模型后检测结果几乎全错模型类别数量或置信度阈值差异打印输出shape,确认类别数,重新调阈值
加载模型时CPU占用异常高首次推理可预热的物理优化项目启动时在后台跑一次空白推理做预热

这里重点提一个隐蔽问题:Output Metadata 里显示的 Dimension 值如果是 -1,拿 output.Dimensions 打印时可能返回0或 -1,在低版本的 OnnxRuntime 里尤其明显。遇到这种情况不要直接依赖元数据里的维度去写死下标,用实际拿到张量后的 Dimensions 为准。

另一个值得分享的经验是置信度阈值的选取。很多人喜欢把 confidenceThreshold 调到0.5以上,想着过滤更多误检,但对于复杂场景或小目标,YOLO模型的输出置信度普遍偏低,0.5会漏掉大量真目标。我一般从0.25起步,配合结果画在图上人工看一眼,确认误检和漏检平衡后再调。

调试阶段强烈建议先跑一张训练集里模型表现好的图,确认整条链路没毛病,再换成真实场景图。如果第一张测试图就用极端场景,模型表现不佳时会让人分不清是代码问题还是模型本身的能力边界。

10. 性能优化与扩展方向

跑通基础功能后,如果要用到实时视频流或高分辨率相机,有几个立竿见影的优化点。

第一个是预热。第一次调用 Run 时,ONNX Runtime要初始化内存池和线程池,实际耗时往往是后续推理的好几倍。在程序启动时用一张全零或全灰图跑一次空推理,能把这个开销提前吃掉,后续检测延迟就稳定了。

第二个是线程数控制。OnnxRuntime的CPU版本默认会用满所有逻辑核心,但很多桌面应用还需要同时做UI渲染、视频解码,如果推理线程把CPU全占满,整个应用会卡顿。可以显式设置 SessionOptions.IntraOpNumThreads 和 InterOpNumThreads,通常取物理核心数的一半左右,在延迟和并发之间平衡:

var options = new SessionOptions(); options.AddSessionConfigEntry("session.intra_op.allow_spinning", "0");

第三个是输入尺寸优化。虽然YOLO官方推荐640×640,但如果检测目标在画面中占比不低,512×512甚至416×416也能保持很好的效果,推理延迟却能下降30%到50%。这个需要针对业务场景调,不是越小越好,尺寸小了密集小目标会漏检。

第四个是GPU。如果目标机器有NVIDIA显卡,把包换成 Microsoft.ML.OnnxRuntime.GPU,代码几乎不用改,前提是CUDA和cuDNN版本与OnnxRuntime匹配。CUDA环境不匹配时,GPU包会在创建Session时报错,这也是另一个高频踩坑点,装GPU包之前先确认版本矩阵。

扩展方向上,这套C#代码稍加改动就能迁移到视频流。把 Detect 方法的输入从单个 Mat 换成 VideoCapture 解码出的帧,检测后直接画框渲染,就是实时检测。再进一步可以加一个目标跟踪模块,用简单的IOU匹配让相同目标保持一致的ID,但那就是另一个话题了。至少ONNX推理这一层,C#这边已经足够稳,不需要再换技术栈。

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

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

立即咨询