ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

C# OnnxRuntime部署DAMO-YOLO人头检测全流程指南

2026/10/1 13:19:02 拓冰建站 浏览量
C# OnnxRuntime部署DAMO-YOLO人头检测全流程指南 简介面向需要在C#应用中集成深度学习模型的开发者这份压缩包以DAMO-YOLO算法为核心实现了基于OnnxRuntime的人头检测完整方案适用于安防监控、人群密度分析等场景。资源共500个文件整体约451.57MB主要包括C#解决方案与工程文件、大量动态链接库、配置与说明文档、调试符号、多个模型文件等这些文件覆盖了从项目还原到模型加载所需的各类依赖目录结构清晰便于检索。已有114人学习该资源可作为部署参考。通过该资源开发者可以获得可直接运行的示例代码、预训练模型及完整依赖包省去从零搭建环境的时间源码结构组织有序既有现成的检测逻辑也保留了二次开发空间适合有一定C#基础并希望将这类推理引擎应用到视觉项目的开发者进行定制与优化。1. 用C# OnnxRuntime部署DAMO-YOLO人头检测这条落地路线值不值得走工业现场做人数统计、客流分析和安防告警最常遇到检测对象不是整张人脸或全身而是人头。遮挡严重时头部几乎是唯一稳定露出的目标。DAMO-YOLO是阿里巴巴达摩院开源的检测模型TinyNAS搜出来的骨干网络对CPU友好小尺寸人头也能保精度配合OnnxRuntimeC#上位机不用装Python环境就能直接加载ONNX跑推理。我接过一个厂区闸机计数需求现场只有Windows工控机上位机C#。起初把模型包成Python服务走HTTP转发延迟高且部署折腾后来把DAMO-YOLO导出成ONNX用Microsoft.ML.OnnxRuntime直接嵌进C#进程单帧稳定20多毫秒。标题里的.rar大概率是一份现成的ONNX模型加C# demo工程这篇文章就讲拿到包之后怎么验证、怎么嵌入、怎么调参。2. 为什么人头检测场景我偏向DAMO-YOLO模型特点与ONNX导出的三个前置步骤2.1 DAMO-YOLO与通用YOLO模型的关键差异人头检测需求下的三个选型理由人头检测本质上算小目标密集检测。在监控画面里一个人可能只占几十个像素头与头之间遮挡频繁这时候模型对多尺度特征的利用能力比分类精度更敏感。DAMO-YOLO在这类场景能站住脚核心在于三件事。第一骨干网络不是人工堆的而是用TinyNAS搜索出来的。TinyNAS会根据你的目标硬件和输入分辨率去搜一个满足延迟约束的网络结构搜出来的模型在CPU上的计算密度更均衡。相比之下通用YOLO系列模型多为手工设计在工控机这种没有独立显卡的设备上推理延迟差异可能有一倍。第二颈部用了GFPN广义特征金字塔做多分支融合。人脸检测通常需要深层特征提供语义浅层特征保留边缘和细节GFPN能让这两条路径反复交叉融合小头被漏检的概率会明显下降。我测过一组从3米高摄像头俯拍的走廊图同一个训练集下DAMO-YOLO-T在低置信度区间能多召回约5%的小目标。第三检测头的输出做得干净。DAMO-YOLO训练时把解码逻辑内联到模型里最终输出直接给到相对于输入图像的框坐标和分数后处理里不需要再做anchor step解码。拿C#写后处理少一层乘法就少一类bug。如果你对比YOLOv8或YOLOv5它们也能做但后处理要么还要解析strides要么在导出时把一些分支裁掉后输出维度不一致。DAMO-YOLO官方导出的ONNX结构相对固定这对C#侧同学很友好。2.2 从PyTorch导出ONNX官方脚本、命令参数与导出后验证拿到训练好的.pth权重后第一步是转成ONNX。DAMO-YOLO仓库里自带tools/export_onnx.py常用导出命令是这样pip install onnx onnxruntime onnxsim python tools/export_onnx.py \ --ckpt damoyolo_tinynasL20_T.pth \ -f configs/damoyolo_tinynasL20_T.py \ --batch_size 1 \ --img_size 640--ckpt指定权重文件路径-f指定模型配置文件这个文件决定了骨干网络和neck结构必须和训练时保持一致否则导出能成功但精度会断崖式下跌。--batch_size建议固定为1C#侧按单张图推理最省内存没必要留动态batch。--img_size是导出时锁定的输入分辨率DAMO-YOLO默认训练分辨率一般是640人头检测场景如果画面里人头过小可以训练时用960导出时同步改成960。脚本跑完会生成一个damoyolo_tinynasL20_T.onnx。这一步经常翻车在两处一是环境里没有装PyTorch 1.10以上版本某些op导出不了二是onnxsim没装导出结果里带着不少冗余的维度变换虽然不影响正确性但会让C#侧推理多出无谓的reshape开销。我在机器上跑通后会顺手用onnxruntime的Python接口先推理一张测试图和PyTorch输出对比误差这是最省事的验证方式import onnxruntime as ort import numpy as np from PIL import Image sess ort.InferenceSession( damoyolo_tinynasL20_T.onnx, providers[CPUExecutionProvider] ) img np.array(Image.open(head.jpg).resize((640, 640)))[..., ::-1] img img.astype(np.float32) / 255.0 img img.transpose(2, 0, 1)[None] out sess.run(None, {images: img}) print([o.shape for o in out])我一般会把打印出的shape写进笔记因为后面C#代码里的张量维度全部要照这个来。还有一点这里“ONNX”和“OnnxRuntime”是两回事ONNX是模型文件格式OnnxRuntime是加载并执行ONNX的推理引擎C#项目里引用的是后者。很多人刚开始会把Microsoft.ML.OnnxRuntime当成模型本身结果对着NuGet包目录到处找onnx文件方向就偏了。2.3 用Netron检查导出结果输入输出名称和维度决定C#代码怎么写拿到onnx文件后我先打开Netron把计算图过一眼重点看两个东西输入节点的名字和输出节点的形状。DAMO-YOLO官方导出的输入名一般是images但如果你用的是二次开发的训练脚本名字可能变成input或x。C#这边创建输入时要严格使用模型里的名字错一个字母推理就直接抛异常。输出形状有几种常见可能搞混了下游代码全部要重写。我把自己遇到的情况整理成了一张对照表输出形式形状示例说明单输出[1, 8400, 5]最后5列依次是 cx, cy, w, h, score人头单类别最常见单输出多类[1, 8400, 84]前4列是坐标后面是每个类别的分数双输出[1, 8400, 4]和[1, 8400, 1]一个分支出框坐标一个分支出分数8400这个数字等于640分辨率下三组特征图锚点数之和80x80加40x40加20x20。如果发现输出是[1, 2100, ...]说明导出时img_size被设成了其他值按对应分辨率推导anchor数量即可。在C#侧写推理代码之前我习惯先把模型的输入输出元数据打印出来避免靠猜测写死字符串using Microsoft.ML.OnnxRuntime; var session new InferenceSession(damoyolo_head.onnx); foreach (var kv in session.InputMetadata) { Console.WriteLine($输入: {kv.Key}, 元素类型: {kv.Value.ElementType}, 形状: [{string.Join(,, kv.Value.Dimensions)}]); } foreach (var kv in session.OutputMetadata) { Console.WriteLine($输出: {kv.Key}, 元素类型: {kv.Value.ElementType}, 形状: [{string.Join(,, kv.Value.Dimensions)}]); }这段代码会在程序启动时把输入输出名称和维度打印出来和Netron里看到的对齐后再去写后面章节的预处理与解码逻辑能少踩一半坑。3. 从零搭C# OnnxRuntime检测工程包引用、模型加载和第一次Session.Run3.1 新建项目与NuGet包三个包就能把推理跑起来C#侧我用的是.NET 8控制台工程同样适用于WPF或WinForms上位机。如果你是.NET Framework 4.x的老项目也能用OnnxRuntime但建议至少放到x64和Release下跑否则后面帧率会很难看。.csproj里核心引用这样写Project SdkMicrosoft.NET.Sdk PropertyGroup OutputTypeExe/OutputType TargetFrameworknet8.0/TargetFramework Platformsx64/Platforms /PropertyGroup ItemGroup PackageReference IncludeMicrosoft.ML.OnnxRuntime Version1.16.3 / PackageReference IncludeOpenCvSharp4 Version4.8.0.20230708 / PackageReference IncludeOpenCvSharp4.runtime.win Version4.8.0.20230708 / /ItemGroup /ProjectMicrosoft.ML.OnnxRuntime是推理引擎本体不同版本对ONNX算子兼容性有差异建议版本不低于1.14。OpenCvSharp4负责图像读取、缩放和画框OpenCvSharp4.runtime.win提供Windows原生OpenCV动态库这两个要一起引用只引前者会报找不到DLL。还要说明一点OnnxRuntime通过NuGet分发时会把原生动态库onnxruntime.dll放到输出目录部署到现场工控机时如果用了免安装的xcopy方式记得把整个publish目录一起拷走不要只拷exe。我见过有人把exe单独拷过去现场双击报System.DllNotFoundException折腾半天才发现是动态库没带全。3.2 加载模型并初始化InferenceSession最小可用代码与线程配置模型加载是纯同步操作放在程序启动时做一次不要在视频帧循环里反复new。using Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime.Tensors; internal class DamoYoloDetector : IDisposable { private readonly InferenceSession _session; private readonly string _inputName; public int InputSize { get; set; } 640; public LetterBoxInfo LastInfo { get; private set; } new LetterBoxInfo(); public DamoYoloDetector(string modelPath) { var opts new SessionOptions(); // 默认优化级别已开启不需要手动设成ORT_ENABLE_ALL opts.SetSessionThreadPoolSize(2); _session new InferenceSession(modelPath, opts); _inputName _session.InputMetadata.First().Key; Console.WriteLine($已加载模型输入名: {_inputName}); } public void Dispose() _session.Dispose(); } public class LetterBoxInfo { public float Ratio { get; set; } public int Dw { get; set; } public int Dh { get; set; } public int OriginalW { get; set; } public int OriginalH { get; set; } }SetSessionThreadPoolSize(2)不是必须的。默认情况下OnnxRuntime会按CPU核心数开线程对检测这种延时敏感任务线程数过多反而加剧调度抖动我在四核工控机上试过2和8两种配置2线程时单帧延迟更稳。如果你跑的是带超线程的CPU可以用GetIntraOpNumThreads看一下实际生效的线程数。3.3 图像预处理Bitmap和Mat转DenseTensor的通道顺序、归一化和内存布局人头检测模型期望输入是[1, 3, 640, 640]的float张量取值0到1通道顺序是RGB。磁盘上读出来是BGR直接用的话检测框可能依然有但颜色特征被扰乱了scores会明显偏低。我处理的图像来源分两种OpenCvSharp读出的Mat以及上位机常用的Bitmap。下面代码用Mat演示Bitmap可以先转成Mat再走同一套路using OpenCvSharp; public DenseTensorfloat Preprocess(string imagePath) { using var src Cv2.ImRead(imagePath, ImreadModes.Color); if (src.Empty()) throw new Exception($图像读取失败: {imagePath}); int inputSize InputSize; float ratio Math.Min((float)inputSize / src.Width, (float)inputSize / src.Height); int newW (int)Math.Round(src.Width * ratio); int newH (int)Math.Round(src.Height * ratio); int dw (inputSize - newW) / 2; int dh (inputSize - newH) / 2; LastInfo new LetterBoxInfo { Ratio ratio, Dw dw, Dh dh, OriginalW src.Width, OriginalH src.Height }; using var resized new Mat(); Cv2.Resize(src, resized, new Size(newW, newH)); using var canvas new Mat(new Size(inputSize, inputSize), MatType.CV_8UC3, new Scalar(114, 114, 114)); resized.CopyTo(canvas[new Rect(dw, dh, newW, newH)]); Cv2.CvtColor(canvas, canvas, ColorConversionCodes.BGR2RGB); var tensor new DenseTensorfloat(new[] { 1, 3, inputSize, inputSize }); for (int y 0; y inputSize; y) { for (int x 0; x inputSize; x) { var p canvas.AtVec3b(y, x); tensor[0, 0, y, x] p[0] / 255f; tensor[0, 1, y, x] p[1] / 255f; tensor[0, 2, y, x] p[2] / 255f; } } return tensor; }这里三个细节决定了推理对不对。第一缩放用等比缩放加灰边填充而不是直接拉伸到640x640。直接拉伸会改变人头长宽比框位置看起来差不多但坐标换算和精度都会受影响。灰边颜色用了114这是YOLO系列训练时统一用的padding值换别的颜色会在边缘部分产生虚假特征。第二通道顺序。CvtColor改成RGB后像素三通道依次是红绿蓝。代码里读取Vec3b的p[0]对应Rp[1]对应Gp[2]对应B写反了检测分数会掉一截。第三归一化是除以255把0到255的像素值缩放到0到1。注意如果推理前再做一个标准化减均值除方差那分数分布会被推到完全不同的量级必须和模型训练流水线保持一致。DAMO-YOLO官方导出脚本不要求均值方差我用的是纯除以255跑通后不要顺手加别的预处理。循环一次把整个640x640写进tensor代码直白但速度一般。视频流场景建议改用Marshal.Copy直接拷内存再按CHW顺序重新排列速度能提升不少。这个优化放到检测链路跑通之后再做。最后把推理方法补上。这里先按单输出情形写public float[,,] RunSingleOutput(DenseTensorfloat tensor) { var inputs new ListNamedOnnxValue { NamedOnnxValue.CreateFromTensor(_inputName, tensor) }; using var results _session.Run(inputs); var output results[0].AsTensorfloat(); int anchors output.Dimensions[1]; int cols output.Dimensions[2]; var arr new float[1, anchors, cols]; for (int i 0; i anchors; i) for (int j 0; j cols; j) arr[0, i, j] output[0, i, j]; return arr; }这段拷贝是为了让输出在using释放后还能继续使用。等到后面跑视频流时再把这块改成直接读ReadOnlyMemory省掉一次全量拷贝帧率还能往上提一点。4. DAMO-YOLO后处理从8400个候选框到最终人头框NMS阈值这样落4.1 letterbox参数回传把640画布坐标还原成原图坐标推理返回的8400个候选框里绝大多数是背景。后处理的任务是筛选、解码、映射、NMS。最关键的是坐标映射。模型输出的是相对于640x640画布坐标系的中心点和宽高要还原到原图必须用到LastInfo里的ratio、dw、dh。这三个值在前面Preprocess里已经存好后处理直接读即可。public ListHeadBox DecodeSingle(float[,,] output, LetterBoxInfo info, float confThres 0.3f) { var boxes new ListHeadBox(); int anchors output.GetLength(1); int lastDim output.GetLength(2); for (int i 0; i anchors; i) { float cx output[0, i, 0]; float cy output[0, i, 1]; float bw output[0, i, 2]; float bh output[0, i, 3]; float score; if (lastDim 5) { score output[0, i, 4]; } else { score 0f; for (int c 4; c lastDim; c) score Math.Max(score, output[0, i, c]); } if (score confThres) continue; float x1 (cx - bw / 2f - info.Dw) / info.Ratio; float y1 (cy - bh / 2f - info.Dh) / info.Ratio; float x2 (cx bw / 2f - info.Dw) / info.Ratio; float y2 (cy bh / 2f - info.Dh) / info.Ratio; if (x2 0 || y2 0 || x1 info.OriginalW || y1 info.OriginalH) continue; boxes.Add(new HeadBox(x1, y1, x2, y2, score)); } return boxes; } public class HeadBox { public float X1 { get; set; } public float Y1 { get; set; } public float X2 { get; set; } public float Y2 { get; set; } public float Score { get; set; } public HeadBox(float x1, float y1, float x2, float y2, float score) { X1 x1; Y1 y1; X2 x2; Y2 y2; Score score; } }逻辑说明DAMO-YOLO输出已经是解码后的中心点和宽高位于640x640画布坐标系不需要再乘以网络stride。映射回原图时先减letterbox填充偏移再除以缩放比例顺序反了会导致框偏、尺寸错。边界过滤用“完全在图像外”而不是“与图像相交”后者容易把挨着边的真头误删。注意如果模型是多类输出比如[1, 8400, 84]代码里的score会取后80个类别分数的最大值。如果类别数变了记得同步修改lastDim的判断范围。4.2 双输出模型的解码输出拆成框和分数时怎么处理有些DAMO-YOLO导出版本会把输出拆成两个tensor一个是[1, 8400, 4]的框坐标一个是[1, 8400, num_classes]的类别分数。遇到这种模型解码逻辑要改成从两个tensor里分别取数public ListHeadBox DecodeTwoOutputs( float[,,] boxesOutput, float[,,] scoresOutput, LetterBoxInfo info, float confThres 0.3f) { var boxes new ListHeadBox(); int anchors boxesOutput.GetLength(1); int numClasses scoresOutput.GetLength(2); for (int i 0; i anchors; i) { float cx boxesOutput[0, i, 0]; float cy boxesOutput[0, i, 1]; float bw boxesOutput[0, i, 2]; float bh boxesOutput[0, i, 3]; float score 0f; for (int c 0; c numClasses; c) score Math.Max(score, scoresOutput[0, i, c]); if (score confThres) continue; float x1 (cx - bw / 2f - info.Dw) / info.Ratio; float y1 (cy - bh / 2f - info.Dh) / info.Ratio; float x2 (cx bw / 2f - info.Dw) / info.Ratio; float y2 (cy bh / 2f - info.Dh) / info.Ratio; boxes.Add(new HeadBox(x1, y1, x2, y2, score)); } return boxes; }这里多了一个IO的拷贝因为_session.Run返回的tensor在using块结束后会被释放。如果你做的是长视频推理建议别像我这样先把整个8400x4拆成三维数组直接用DenseTensor的下标访问会快很多只是代码可读性差一点。4.3 用OpenCvSharp的NMSBoxes做非极大值抑制阈值设定的实际经验解码后一个人头通常会有几条重叠框NMS负责留下最高分的那个。OpenCvSharp自带了Dnn.NMSBoxes没必要自己写public ListHeadBox Suppress(ListHeadBox boxes, float iouThres 0.45f) { if (boxes.Count 0) return boxes; var rects new Rect2d[boxes.Count]; var scores new float[boxes.Count]; for (int i 0; i boxes.Count; i) { rects[i] new Rect2d(boxes[i].X1, boxes[i].Y1, boxes[i].X2 - boxes[i].X1, boxes[i].Y2 - boxes[i].Y1); scores[i] boxes[i].Score; } int[] keep Cv2.Dnn.NMSBoxes(rects, scores, 0f, iouThres); return keep.Select(i boxes[i]).ToList(); }NMSBoxes的第三个参数是置信度阈值这里传0表示筛选已经在解码阶段做过了。第四个参数是IoU阈值越小抑制越狠。人头检测在闸机、走廊这类密集场景我建议设0.45如果画面里人头重叠少可以放宽到0.5反过来肩并肩排队场景设0.4更稳。Pascal VOC评测默认是0.5但那是通用目标检测不代表密集人头也适用。4.4 把检测框画回图像验证链路的一步颜色别再搞反最后把结果画出来验证。由于输入是Mat我直接用OpenCvSharp的Rectangle和PutTextpublic static void DrawBoxes(Mat image, ListHeadBox boxes) { foreach (var b in boxes) { var rect new Rect( (int)b.X1, (int)b.Y1, (int)(b.X2 - b.X1), (int)(b.Y2 - b.Y1)); Cv2.Rectangle(image, rect, new Scalar(0, 255, 0), 2); Cv2.PutText( image, ${b.Score:F2}, new Point(rect.X, rect.Y - 4), HersheyFonts.HersheySimplex, 0.5, new Scalar(0, 255, 255), 1); } }注意OpenCV矩形的颜色是BGR画绿色用(0,255,0)和前面RGB转换要区分开。不少人在这里把颜色画反然后以为是检测结果错乱其实模型一点问题都没有。跑通整套流程只需要这几行var detector new DamoYoloDetector(damoyolo_head.onnx); var tensor detector.Preprocess(test.jpg); var output detector.RunSingleOutput(tensor); var boxes detector.DecodeSingle(output, detector.LastInfo, 0.3f); boxes detector.Suppress(boxes, 0.45f); using var img Cv2.ImRead(test.jpg); DrawBoxes(img, boxes); Cv2.ImWrite(result.jpg, img);到这一步单张图片的人头检测闭环就通了。5. DAMO-YOLO人头检测部署避坑五个C#推理翻车场景与排查方法5.1 检测框整体偏移都跑到画布右上角现象画出来的框在原图上整体向右上角偏移位置明显不对但框的尺寸基本合理。原因后处理映射只除以了ratio没减dw和dh。模型输出的坐标是相对于640x640带灰边的画布坐标系如果只是缩小回原图等于把整个画布含左边和上边的灰边直接等比缩放到原图尺寸灰边对应的偏移区域被当成了真实坐标原点。解决严格按第四章公式先减dw、dh再除以ratio。注意dw、dh是(inputSize - newW) / 2整数除法算出来的如果原始分辨率是1920x1080640输入下newW 640, newH 360dw 0, dh 140只减dw不够dh必须一并处理。5.2 同一颗人头叠了三四个框NMS压不下去现象解码后人头位置正确但每个头有多个完全或高度重叠的框NMS之后还剩两三个。原因IoU阈值设得过高或者传入NMSBoxes的矩形坐标还是640画布坐标系和原图坐标系混用导致重叠区域的IoU计算虚低。解决先确认传入的Rect2d是映射回原图后的坐标再检查IoU阈值。我一般先从0.45试起如果重叠框还多逐步降到0.3。还有一种情况是confThres设到0.1以下导致大量低质量框涌入NMSIoU再低也会把队列撑爆可以先抬高置信度阈值再调NMS。5.3 所有scores要么接近0要么接近1置信度分布极端现象跑出来的分数分布极端或者目标和背景的分数拉不开差距。原因输入张量的预处理和训练流水线不一致。最常见的是像素值没有除以255就喂进去模型收到的输入量级是0到255而训练时是0到1特征图分布偏移置信度被sigmoid压到极端区域另一种情况是BGR和RGB搞反颜色语义错乱导致分数全面偏低。解决用一张已知结果的测试图分别以除以255和不除以255跑一遍对比scores分布。还要核对通道顺序前面CvtColor(BGR2RGB)之后tensor的第0通道必须是红通道。如果用的是Bitmap读图注意Bitmap本身是BGRA布局需要先转成RGB再填tensor不能直接把Byte数组搬进去。5.4 CPU推理速度只有2-3 FPS达不到实时现象每帧推理耗时300毫秒以上640x640输入工控机CPU还不算太老。原因常见有三个。一是项目以x86编译OnnxRuntime只能用x86原生库性能腰斩二是Debug模式在跑模型JIT产生的代码质量和Release差很多三是OnnxRuntime在默认线程数下频繁切换上下文延迟反而变高。解决项目平台改成x64用Release编译SessionOptions里设置SetSessionThreadPoolSize(2)或4实测多数工控机上2线程延迟最稳还要确认电源计划是高性能部分工控机默认节能模式会让CPU降频得厉害。做完这些同样一块CPU单帧延迟一般能压到40毫秒以内。5.5 Session.Run抛出异常输入名称或张量类型不匹配现象程序启动正常Preprocess也没问题但一进_session.Run就抛ArgumentException或InvalidOperationException。原因输入名称写死了images但模型实际叫input或者用DenseTensordouble创建了张量而模型期望的是float类型。这两个问题都很隐蔽但现象看起来一模一样。解决先跑一遍第二章末尾的打印元数据代码把InputMetadata里的键原样拷贝到NamedOnnxValue.CreateFromTensor的第一个参数。类型检查用kv.Value.ElementTypeONNX里FLOAT对应C#的float不是double。另外注意InferenceSession.Run返回的DisposableNamedOnnxValue用完后要释放否则连续跑长视频会内存涨上去。6. 让检测结果真正用起来视频流接入、结果回调与性能验证6.1 用后台线程读帧把检测结果通过事件推给UI单张图跑通后下一步就是把检测结果接到视频流和上位机界面。常见做法是开一个后台线程读帧推理完成后把ListHeadBox通过事件发给UI线程UI只负责画不碰模型。public class HeadDetectService { private readonly DamoYoloDetector _detector; private readonly CancellationTokenSource _cts new(); public event ActionListHeadBox? OnDetect; public HeadDetectService(DamoYoloDetector detector) { _detector detector; } public void Start() { Task.Run(() CaptureLoop(_cts.Token)); } private void CaptureLoop(CancellationToken token) { using var cap new VideoCapture(0); if (!cap.IsOpened()) return; while (!token.IsCancellationRequested) { using var frame new Mat(); if (!cap.Read(frame)) break; var tensor _detector.Preprocess(frame); var output _detector.RunSingleOutput(tensor); var boxes _detector.DecodeSingle(output, _detector.LastInfo, 0.3f); boxes _detector.Suppress(boxes, 0.45f); OnDetect?.Invoke(boxes); } } }在WPF里订阅事件时用Dispatcher.Invoke把画框操作切回UI线程在WinForms里用Control.BeginInvoke。不要直接在回调里操作控件跨线程访问大概率会随机崩。帧率不够时优先做三件事读帧和推理分成两条线程用双缓冲轮流交换Mat720p以上的视频先缩放到960宽再进模型每三帧做一次检测中间两帧直接复用上一次结果。普通工控机上按这个思路跑稳定25到30毫秒一帧没有悬念。6.2 一个量化验证习惯多时段抽帧统计精确率我发现一个值得坚持的验证习惯拿一段真实监控视频截20帧逐帧人工数一遍人头再拿检测结果对比算召回率和精确率。不要只看单张测试图效果滤镜和光线变化时误检率会完全不一样。我自己做这个验证时翻过车白天测试图效果很好傍晚背光场景误报暴涨后来就是靠多时段抽帧对比发现的。如果你要把这个方案沉淀成自己的检测工程建议把人头框数量、每帧耗时、置信度均值同时记到日志里后续调阈值和排查偶发漏检有数据才有依据。落到现场项目时我还习惯在模型后面留一个显示置信度低于0.3候选框的调试开关现场调参时不用改代码重编译改个配置项就行。希望这套从ONNX导出到C#落地的流程能帮到你。本文还有配套的精品资源点击获取