
简介面向C#与ONNX Runtime开发者的PP-Vehicle车辆分析完整源码包集合车辆检测、车型识别、车辆颜色识别与车牌检测四种主流视觉任务适合桌面应用、智能交通演示或竞赛项目快速落地。压缩包内共138个文件主要包含ONNX模型文件、C#工程源码、项目配置文件、DLL依赖以及可直接运行的exe程序资源包整体大小53.65MB目录结构完整从模型加载到推理输出均有清晰示例。已有325人学习下载。资源完整覆盖PP-Vehicle在C#环境中的部署流程包含模型调用、图像预处理、结果解析等关键实现环节并配有四种任务对应的专用模型与后处理代码便于理解多模型组合调用的整体逻辑。使用者可参照工程配置在Visual Studio中直接打开调试并将车辆分析能力无缝集成到自有系统有效降低ONNX模型应用门槛。1. 为什么 C# Onnx 跑 PP-Vehicle 车辆分析三个现实原因第一次见到「C# Onnx PP-Vehicle 车辆分析」这个标题时我第一反应是又一个要在上位机里折腾 Python 部署环境的需求。后来真在停车场道闸项目里把 PP-Vehicle 导出成 ONNX再用 OnnxRuntime 在 C# 里跑起来才发现这条路比想象中干净不装 PaddlePaddle、不维护 Python 进程一套 NuGet 包就能把车辆检测、车型识别、车辆颜色识别、车牌检测全部串起来。这个方案适合用 WinForm/WPF 写 C# 上位机的工程师尤其项目方只给 Windows 工控机、又不允许额外运行时依赖的时候。它能解决厂家 SDK 黑匣子太贵、Python 模型服务难嵌入同一个进程的痛点。2. 拆开 PP-Vehicle 的模型包车辆检测、车型颜色、车牌检测各是什么模型PP-Vehicle 官方仓库里看是一个应用工程但导出 ONNX 后其实是一组独立模型通常可以拆成三份车辆检测模型、车辆属性分类模型、车牌检测模型。很多人一上来就想找一个「全能大模型」跑全部结果这是误区。做落地前先弄清楚每个模型输入输出是什么后面 C# 侧才知道怎么设计推理类和数据结构。2.1 车辆检测模型Anchor-Free 的 PP-YOLOE 对 C# 后处理更友好PP-Vehicle 默认车辆检测模型是基于 PP-YOLOE 系列的目标检测网络。和 YOLOv5 这类 Anchor-Based 模型相比PP-YOLOE 是 Anchor-Free 设计导出 ONNX 后输出层不需要在 C# 里再做 anchor decode后处理能省掉一截手工代码这对不熟悉深度学习细节的上位机工程师非常关键。不同版本的 PP-Vehicle 导出后检测输出通常是一个[1, N, 6]或[1, N, 7]形状的张量N 表示最大候选框数量最后一维里是[cx, cy, w, h, score, class_id]这类信息。但这里必须提醒一句不同导出方式输出的排列顺序不完全一样我不能只凭经验硬套。拿到模型第一件事不是写预处理而是枚举模型的输入输出节点名和维度。using Microsoft.ML.OnnxRuntime; using var session new InferenceSession(vehicle_det.onnx); Console.WriteLine(Inputs:); foreach (var kv in session.InputMetadata) { var shape string.Join(,, kv.Value.Dimensions); Console.WriteLine($ {kv.Key}: {kv.Value.ElementType.Name} [{shape}]); } Console.WriteLine(Outputs:); foreach (var kv in session.OutputMetadata) { var shape string.Join(,, kv.Value.Dimensions); Console.WriteLine($ {kv.Key}: {kv.Value.ElementType.Name} [{shape}]); }这段代码通过InferenceSession.InputMetadata和OutputMetadata读取模型原生的输入输出信息是排查 ONNX 模型怎么运行的起点。注意Dimensions里可能出现 -1表示该维度是动态的比如[-1, 3, 640, 640]C# 侧创建 Tensor 时就要严格按1,3,640,640来填。后面的代码不要用硬编码的输入名而要用这里打印出来的Key去索引避免节点名不一致导致运行时异常。2.2 车型和车辆颜色分类模型不是检测模型PP-Vehicle 的「车型识别」和「车辆颜色识别」通常由一个车辆属性模型完成输入是车辆检测框抠出来的子图输出是多分类概率向量。它不负责画新框只负责判断这个子图属于什么车型、什么颜色。常见的车型类别有轿车、SUV、卡车、客车颜色类别有黑、白、银、蓝、红等但不同训练版本的标签顺序有可能完全不同。属性模型落地最大的坑是类别顺序。官方模型配套的label_list.txt必须跟着 ONNX 一起保留C# 里读取标签文件而不是写死数组是最稳的做法。var typeLabels File.ReadAllLines(attr_type_labels.txt) .Where(line !string.IsNullOrWhiteSpace(line)) .Select(line line.Trim()) .ToArray(); var colorLabels File.ReadAllLines(attr_color_labels.txt) .Where(line !string.IsNullOrWhiteSpace(line)) .Select(line line.Trim()) .ToArray();这段代码用Trim()去掉文件每行末尾的换行和空字符用Where过滤空白行最终得到和模型输出索引一一对应的标签数组。为什么要从文件读而不是硬编码因为训练脚本里类别顺序一旦变化你写死的white, black, silver就会错位识别结果会变成「把白车识别成黑车」这种看起来特别玄学、实际是索引错位的问题。2.3 车牌检测文本检测模型只出框不读字符标题里写的是「车牌检测」这是一个容易被忽略的边界。PP-Vehicle 里的车牌检测实际用的是 PP-OCR 系列文本检测模型它只输出车牌区域的四边形顶点坐标不输出车牌字符串。要读车牌号还需要再接一个 OCR 识别模型两者是不同环节。车牌检测输出的坐标一般是四个点不是轴对齐矩形。大角度、倾斜车牌在检测结果里很常见C# 侧不能直接把四点横纵坐标取min/max当作外接矩形否则会把车牌附近的背景切进去。我一般用 OpenCvSharp 的MinAreaRect得到带角度的最小外接矩形再投影成正矩形。// points 是模型输出的 4 个 Point 集合 var minRect Cv2.MinAreaRect(points); var box minRect.BoundingRect(); // box 就是后续裁剪车牌用的矩形 using var plateCrop new Mat(frame, box);MinAreaRect返回旋转矩形BoundingRect()把它扩展成轴向正矩形。这样大多数情况够用但对超大角度车牌正矩形会带进大面积背景后面接车牌识别时仍然吃亏。建议先检测出四点再做透视校正目标尺寸按车牌长宽比 3:1 来定比如240x80。这一步到「检测」已经完成是否继续做 OCR 取决于项目需求。3. 搭建 C# Onnx 推理工程从 NuGet 到第一帧检测输出把模型研究清楚后就到了最实在的工程阶段。C# 里跑 ONNX 不复杂常见做法是引用官方 OnnxRuntime NuGet 包。这一章会给出一个最小可运行的车辆检测类并且把预处理的细节讲透因为预处理错一分模型输出就偏一丈。3.1 选包与版本OnnxRuntime 和 OpenCvSharp 的组合首先要分清两个东西onnx是一种模型格式onnxruntime是推理引擎。项目里要的是用 OnnxRuntime 去运行.onnx文件而不是自己去解析 onnx 格式。C# 侧最直接的两个包是Microsoft.ML.OnnxRuntime和OpenCvSharp4.Windows前者负责模型加载和前向计算后者负责图像读取、画框和颜色转换。dotnet add package Microsoft.ML.OnnxRuntime dotnet add package OpenCvSharp4.Windows dotnet add package OpenCvSharp4.ExtensionsOpenCvSharp4.Windows自带原生 OpenCV 库省掉手动配 DLL 的步骤。这里要特别注意目标平台项目必须设置为 x64否则加载原生 dll 时会报BadImageFormatException或找不到 dll。关于onnxruntime和onnx的区别可以在团队内部一句话讲清楚onnx是模型文件onnxruntime是执行引擎C# 项目里引用的是后者。如果你的部署目标是 Linux 边缘盒子再去考虑onnx转ncnn在 Windows 上位机场景里直接用 OnnxRuntime 最省事。3.2 用 SessionOptions 控制算力资源InferenceSession是 OnnxRuntime 的核心对象它负责加载模型图和运行时资源。创建 Session 时不要用默认参数至少要配置GraphOptimizationLevel和线程数。PP-Vehicle 这类多模型场景Session 的创建、销毁成本非常高正确做法是每个模型在程序启动时创建一个 Session然后在整个生命周期里复用。var options new SessionOptions { GraphOptimizationLevel GraphOptimizationLevel.ORT_ENABLE_ALL, IntraOpNumThreads Environment.ProcessorCount / 2, EnableMemoryPattern true }; using var session new InferenceSession(vehicle_det.onnx, options);ORT_ENABLE_ALL会让 OnnxRuntime 对计算图做尽可能多的融合优化CPU 推理速度提升明显。IntraOpNumThreads控制单算子内部的线程数设置成逻辑核心数的一半是为了避免和图像采集线程抢 CPU工控机上尤其重要。EnableMemoryPattern尽量保持默认开启减少运行时内存分配。3.3 最小推理代码从字节流到模型输出接着写一个可复用的检测器封装。这个类负责加载模型、预处理图像、执行Run并返回一维浮点数组后续的框解析放到流水线里单独处理。using Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime.Tensors; using OpenCvSharp; using System.Runtime.InteropServices; public class VehicleDetector : IDisposable { private readonly InferenceSession _session; private readonly string _inputName; private readonly int _inputSize; public VehicleDetector(string modelPath, int inputSize 640) { var opts new SessionOptions { GraphOptimizationLevel GraphOptimizationLevel.ORT_ENABLE_ALL, IntraOpNumThreads Environment.ProcessorCount / 2 }; _session new InferenceSession(modelPath, opts); _inputName _session.InputMetadata.Keys.First(); _inputSize inputSize; } public float[] Run(Mat src) { var tensor Preprocess(src); var inputs new ListNamedOnnxValue { NamedOnnxValue.CreateFromTensor(_inputName, tensor) }; using var outputs _session.Run(inputs); return outputs[0].AsTensorfloat().ToArray(); } private DenseTensorfloat Preprocess(Mat src) { float scale Math.Min((float)_inputSize / src.Width, (float)_inputSize / src.Height); int newW (int)(src.Width * scale); int newH (int)(src.Height * scale); int padX (_inputSize - newW) / 2; int padY (_inputSize - newH) / 2; using var resized src.Resize(new Size(newW, newH)); using var padded new Mat(_inputSize, _inputSize, MatType.CV_8UC3, new Scalar(114, 114, 114)); resized.CopyTo(padded[new Rect(padX, padY, newW, newH)]); var data new byte[_inputSize * _inputSize * 3]; Marshal.Copy(padded.Data, data, 0, data.Length); var floatArr new float[3 * _inputSize * _inputSize]; for (int i 0; i _inputSize * _inputSize; i) { float b data[i * 3 0] / 255f; float g data[i * 3 1] / 255f; float r data[i * 3 2] / 255f; floatArr[i] r; floatArr[_inputSize * _inputSize i] g; floatArr[2 * _inputSize * _inputSize i] b; } return new DenseTensorfloat(floatArr, new[] { 1, 3, _inputSize, _inputSize }); } public void Dispose() _session.Dispose(); }核心是Preprocess里的 letterbox 处理按比例缩放不足 640 的边用灰度 114 填充保持原始长宽比避免直接Resize造成的目标形变。Marshal.Copy(padded.Data, data, 0, data.Length)把内存中的图像字节拷贝到托管数组后续循环完成 HWC 到 CHW 的通道转换并归一化到 0~1。注意 OpenCV 读进来是 BGR所以填入DenseTensor时把 R 通道放最前面B 放最后。_session.Run(inputs)返回多个输出这里的outputs[0]只取第一个如果模型有多个输出头需要按OutputMetadata顺序确认哪个是检测框、哪个是类别概率。4. 把三种模型串成车辆分析流水线顺序、阈值和异步队列单独把检测模型跑通只是第一步。实际的项目场景是摄像头一帧画面进来最终要输出「画面里有几辆车、每辆车是什么车型、什么颜色、车牌框在哪」。这三个模型要按正确顺序配合处理结果还要画在原图上并且不能把 UI 线程卡死。4.1 先检测、再属性、最后车牌步骤和抠图尺寸正确顺序应该是先跑车辆检测拿到每个车辆的[cx, cy, w, h]框再对每个框抠图送进属性和车牌模型。不能反过来因为车牌检测模型在全图上找车牌会找到路牌或其他干扰文本而且同一个画面的多辆车会互相干扰。// 1. 车辆检测得到 boxes var rawDet vehicleDetector.Run(frame); var boxes ParseDetection(rawDet, threshold: 0.5f); foreach (var box in boxes) { // 2. 裁剪车辆子图送属性模型 using var roi new Mat(frame, box.Rect); var attrRaw attrDetector.Run(roi); var (typeIndex, colorIndex) ParseAttr(attrRaw); // 3. 同一框内重新裁剪送车牌检测模型 using var roiPlate new Mat(frame, box.Rect); var platePoints plateDetector.Run(roiPlate); // 注意platePoints 是相对 box.Rect 左上角的偏移映射回原图要加回 box.Rect.X / Y }这里最容易被忽视的是坐标映射。车牌检测输入的是roi也就是车辆框裁剪出来的子图模型输出的四点坐标是相对子图的坐标画到原图上必须加回box.Rect.X和box.Rect.Y否则车牌框会全部错位到画面左上角。属性模型输入尺寸通常比较小常见的是 224 或 256车牌检测模型输入可以保持 640或者降到 480 来提速不要三个模型都套同一个输入尺寸。4.2 阈值与类别映射这两个参数最值得先调模型后处理阶段的置信度阈值直接影响漏检率和误检率。阈值设太低会出来一堆误选框设太高远处小目标直接消失。环节推荐阈值调整依据车辆检测置信度0.4 ~ 0.5停车场、高速场景用小一点 0.4避免漏远处车辆车型/颜色分类置信度0.6 ~ 0.8低于阈值时输出 unknown不要硬猜车牌检测置信度0.4 ~ 0.5车牌目标小阈值过高容易漏检测模型输出的数组经常是扁平的一维float[]解析前一定要先知道每个框占多少个 float。常见的一个框可能是[cx, cy, w, h, score, class_id]也就是 6 个 float如果带多个类别概率就会变成更多列。下面是一段按固定列数解析的示意public ListBoxInfo ParseDetection(float[] output, float threshold) { var boxes new ListBoxInfo(); int cols 7; // 常见输出: cx, cy, w, h, score, class_id, 扩展项 int rows output.Length / cols; for (int i 0; i rows; i) { float score output[i * cols 4]; if (score threshold) continue; boxes.Add(new BoxInfo( output[i * cols 0], output[i * cols 1], output[i * cols 2], output[i * cols 3], score, (int)output[i * cols 5] )); } return boxes; }这段代码用固定列数把一维数组切分成若干候选框命中阈值才保留。需要特别说明这里的cols 7是示意运行时最好从Tensor的Dimensions动态读取 N 值而不是直接拿Length / 7。有些模型输出[1, N, 6]有些是[1, N, 7]列数一变就会解析错位识别结果自然全崩。解析出候选框后还要做 NMS 去重否则同一辆车可能画多个框。4.3 用 Channel 做异步帧队列避免 UI 线程卡成幻灯片上位机里最常见的翻车是把推理直接写在相机FrameGrabbed事件里结果一跑模型 UI 直接黑屏。正确做法是生产者和消费者解耦用一个有界队列缓冲帧推理线程从队列里拿图算完再把结果抛回 UI 线程。var channel Channel.CreateBoundedMat(new BoundedChannelOptions(2) { FullMode BoundedChannelFullMode.DropOldest }); // 相机采集线程 while (capture.IsOpened()) { using var frame new Mat(); if (capture.Read(frame)) await channel.Writer.WriteAsync(frame.Clone()); } // 推理线程 await foreach (var frame in channel.Reader.ReadAllAsync()) { using var frameLocal frame; var result Pipeline.Run(frameLocal); // 通过 Invoke 或 TaskScheduler 回到 UI 线程画框 }队列容量设置为 2 即可DropOldest表示队列满时丢弃最旧帧优先保证最近的画面能被处理。推理线程和采集线程分离后相机采集不会因为模型推理慢而丢帧界面动画也不会卡。注意frame.Clone()是必须的因为Mat对象是引用类型直接传递原图很可能在下一轮采集时被覆盖推理线程拿到的数据已经变了。这个坑在 C# OpenCvSharp 场景里非常经典本质是图像内存生命周期没管理好。5. PP-Vehicle 转 ONNX 到 C# 落地的 7 个避坑记录这条路我走下来最花时间的不是推理逻辑而是各种环境、数据排列、生命周期问题。挑几个有代表性的踩坑记录每条都按「现象 → 原因 → 解决」写希望能帮你少走几趟弯路。5.1 模型输入名是空字符串C# 用名字赋值直接报错现象用InputMetadata.Keys打印时输入名是一个空字符串NamedOnnxValue.CreateFromTensor(, tensor)也能创建但Run时抛异常。原因用 paddle2onnx 导出的部分模型没有给输入输出节点起名字。解决先用 Python 的onnx库把 graph 的输入输出重新命名再重新保存之后 C# 侧就能拿到正常名字。这种「小问题」最容易让人误以为 C# 代码写得不对实际上模型文件本身就不规范。5.2 白车识别成黑车颜色识别结果彻底错乱现象白天停车场的白色轿车属性模型输出的颜色是黑色换角度也一样。原因属性模型输出的分类索引和label_list.txt对不上或者 C# 预处理把 BGR 通道顺序填反了绿色和红色通道整体换位模型看到的颜色是偏色后的伪彩图。解决先打印原始输出索引人工比对标签文件再检查Preprocess里的通道填充顺序OpenCvSharp 读的是 BGR模型训练时输入一般是 RGB通道转换必须写对。这个坑还会影响车型判断但表现更隐蔽。5.3 车牌框是斜的外接矩形切出来的图全变样现象大角度车牌检测输出四个点直接取minX/minY/maxX/maxY裁剪切出来的图里车牌是歪的后面接入 OCR 时几乎全识别错。原因文本检测输出本来就是旋转四边形不是轴对齐框。解决用Cv2.MinAreaRect获取旋转矩形再对裁剪图做透视校正把车牌投影到目标矩形。常规车牌长宽比约 3:1目标尺寸设为240x80是比较稳妥的起点。5.4 CPU 推理一帧 800ms视频流完全卡死现象工控机上跑完整流水线车辆检测加属性加车牌一帧需要 800ms基本没法实时。原因三个模型串行执行各自处理全分辨率图像而且 Session 没开优化。解决GraphOptimizationLevel设为ORT_ENABLE_ALL属性模型输入降到 224车牌检测用 480 分辨率最后还可以对属性模型和车牌模型做 INT8 量化。OnnxRuntime 提供了动态量化能力C# 跑 INT8 模型只换文件不改代码速度提升明显代价是准确率轻微下降。5.5 c#调用c出现 access violation c0000005 的偶发崩溃现象程序跑几分钟到半小时不定时崩溃崩溃点指向 OnnxRuntime 或 OpenCvSharp 的原生 dll事件查看器里是c0000005。原因图像Mat对象被 GC 提前释放或者未托管资源在多线程里交叉销毁。典型情况是推理线程里用了Mat包装方式引用采集线程的图像而采集线程下一帧已经把原内存覆盖。解决所有入队图像必须Clone()推理方法内部用完的Mat用using保证释放不要让Tensor包装的float[]在Run还没结束时被 GC 回收。这是一个典型的 C# 调用 C 原生库时的生命周期问题不是推理逻辑问题。5.6 每帧都创建 InferenceSession内存涨到失控现象程序跑几分钟后内存稳定飙升到 2GB 以上最终被系统杀掉。原因代码里每次Run都new InferenceSessionSession 内部的 graph 和内存池没有及时释放。解决把三个模型的InferenceSession作为成员变量或单例程序启动时创建一次常驻内存。OnnxRuntime 的 Session 创建成本很高反复创建对性能也是致命打击。5.7 夜间和雨雾场景漏检严重现象白天跑得好好的车辆检测一到晚上检测框断断续续颜色识别更是全错。原因PP-Vehicle 默认训练数据以白天为主模型对低光照、有雨雾的输入分布覆盖不足。解决夜间场景降低检测阈值到 0.35 左右属性分类阈值反而提高到 0.7置信度不足时输出unknown而不是硬给一个错误结果。如果项目对夜间准确率有硬指标必须在本地采集夜间数据做约束微调靠调阈值只能救急。6. 让这套车辆分析方案稳定运行预热、内存控制和压测三个模型跑通后最后一步是把「能跑」变成「能稳定跑」。这里分享三个我的固定习惯。第一是预热。OnnxRuntime 第一次执行Run时要做算子选择和内存规划首帧速度明显比后续慢。如果看门狗逻辑只看推理耗时很容易在启动阶段误判为程序卡死。我一般会在程序启动后用一张固定图片循环推理 3 次再开始处理摄像头数据。using var warmupFrame new Mat(warmup.jpg); for (int i 0; i 3; i) { pipeline.Run(warmupFrame); }第二是控制内存。C# 里Mat和DenseTensor都是非托管资源的封装必须确保它们被及时释放。进入 Channel 的帧要Clone()推理线程消费完要Dispose。能用ArrayPoolfloat复用的中间数组尽量复用避免高频 GC 造成卡顿。第三是压测。不要只在现场跑几分钟就验收我会用一段包含白天、夜间、逆光场景的视频文件循环回放每帧记录推理耗时和检测框数量连续跑一小时看内存曲线是否平稳。这样能暴露内存泄漏和偶发崩溃。我现在的习惯是每次发布前都记录模型版本、标签文件哈希和关键阈值到日志里这样线上识别结果异常时能快速定位是模型换错了还是阈值被改动。很多所谓玄学问题最后都出在版本和数据对不上。希望这些细节能帮到你。本文还有配套的精品资源点击获取