ARTICLE DETAIL

建站实战干货

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

C# Onnx PP-Vehicle车辆分析:从模型部署到上位机集成实践

2026/9/28 15:52:38 拓冰建站 浏览量
C# Onnx PP-Vehicle车辆分析:从模型部署到上位机集成实践 简介针对需要在桌面软件中离线集成车辆视觉能力的C#开发者这套基于ONNX Runtime与百度PP-Vehicle模型的源码包提供从车辆检测、车型识别、颜色识别到车牌检测的完整实现项目可直接在Visual Studio中打开运行省去Python环境与推理框架搭建的繁琐环节。RAR压缩包共138个文件以.cs源码、.onnx模型、.dll运行库及.config配置为主同时包含可直接启动的.exe示例程序与.pdb调试符号模型输入输出和推理参数均已配置完毕整包约53.65MB结构紧凑便于对照学习或二次修改。目前已有325人学习浏览适合希望快速上手PP-Vehicle的C#工程师。通过阅读源码可以清晰掌握ONNX模型加载、预处理、推理及后处理的关键环节也能直接复用其中的车辆检测与车牌识别逻辑显著缩短自研算法的开发周期。1. 我为什么盯上这份 C# Onnx PP-Vehicle 车辆分析源码离线识车的下一道坎如果你做过停车场道闸或园区出入口的上位机一定遇到过这种需求本地离线识别车辆输出检测框、车型、车辆颜色再把车牌位置标出来。云端方案延迟和带宽成本不好控Python 推理又和 C# 上位机集成得别扭。我最初看到这份 C# Onnx PP-Vehicle 车辆分析源码时是冲着它在 Windows 下免 Python 环境跑三个任务去的车辆检测、识别车型和颜色、车牌检测。它把 PaddleDetection 的 PP-Vehicle 模型转成 Onnx用 OnnxRuntime 在 .NET 里直接推理适合 C# 上位机项目直接集成。适合谁手上有摄像头或视频流、想离线做车辆分析的 C# 开发者尤其是做工业上位机、园区道闸、道路抓拍的老哥。如果你只想跑个 Demo能跟着步骤把三个模型都跑起来这篇笔记也够用。2. PP-Vehicle 模型拆解三个模型各自吃什么、输出什么PP-Vehicle 是 PaddleDetection 里的车辆分析套件它不是一个模型而是一条流水线先检测车再对每个车框做属性识别最后在整图中找车牌。拿到 C# Onnx 源码实际上拿到的是一个推理工程加三个 Onnx 模型文件。动手改代码之前我强烈建议先把三个模型的输入输出搞清楚否则后面你会一直对着错误坐标怀疑人生。2.1 车辆检测PP-YOLO 系列的后处理重点在 NMS车辆检测模型负责的是“哪里有车”这个粗粒度问题。PP-Vehicle 默认的检测模型一般基于 PP-YOLO 系列输入是一张 640x640 的 RGB 图输出是一串候选框。Onnx 导出后输出形式常见两种一种是 boxes、scores、labels 三个独立张量另一种是合并成[1, N, 6]的矩阵。如果是后者每一行的前 4 个是归一化的 x1、y1、x2、y2第 5 个是置信度第 6 个是类别 id。PP-Vehicle 车辆检测通常只关心 vehicle 这一类所以类别 id 很可能是 0。预处理有个关键点叫 letterbox。PP-YOLO 训练时用等比缩放加灰色 padding 把原始图片统一到正方形推理时也必须这么做。如果你贪省事直接Cv2.Resize到 640x640画面会被拉变形检测框看起来还在但坐标换算后会越来越偏尤其对近处的大车。后处理主要是置信度过滤和 NMS。置信度阈值我一般取 0.4~0.5NMS 的 IoU 阈值取 0.45~0.5。C# 里没有现成的 NMS 函数需要自己写循环源码里这部分是核心。常见车辆检测模型输出结构如下输出形式形状含义boxes[1, N, 4]归一化坐标 x1, y1, x2, y2scores[1, N]置信度labels[1, N]类别 id合并形式[1, N, 6]x1, y1, x2, y2, score, label注意 boxes 里的坐标默认是归一化的映射回原图时要先乘以输入尺寸再减去 padding 偏移最后除以缩放比例。很多人在这一步把顺序搞反导致框贴在图像边缘。2.2 车型和颜色属性模型吃的是 224x224 小图别和检测混淆车型和颜色识别本质是图像分类。检测框出来后把原图的车辆区域裁剪出来缩放到 224x224再做和训练一致的归一化。模型输出是 logits 向量要得到概率需要过 Softmax。车型和颜色是两个独立模型类别维度完全不同标签文件也不能混着用。常见车型类别包括轿车、SUV、面包车、卡车、客车颜色类别包括黑、白、灰、红、绿、蓝、黄、棕具体类别以你手里的 labels 文件为准。为什么属性识别不跟检测用一个模型因为这两个任务样本分布差异太大检测网络擅长空间定位属性网络擅长纹理和外观区分硬塞进一个模型会互相干扰。PP-Vehicle 官方也是把属性和检测分开的。在 C# 工程里这意味着如果画面里有 5 辆车一次完整分析就是 1 次检测推理加 10 次属性推理。所以属性模型的单次推理要压到几毫秒内否则画面车一多就掉帧。属性模型的输出解析有个小坑模型输出的 softmax 概率不会经过温度缩放夜间低光照时颜色类别的概率会普遍偏低但 argmax 仍然会返回一个标签。这里不能只取最大索引必须加一个置信度阈值低于阈值的返回 unknown等下一帧再追。否则你会看到一辆黑色车在晚上永远被识别成深灰色。2.3 车牌检测在全图找小目标而不是在车辆框里找车牌检测模型吃的是整图而不是检测框裁剪后的局部图。这个设计我一开始没想明白后来实践发现车牌在画面里经常只有几十个像素如果先在车辆框内裁剪再检测车牌会进一步变小小目标更容易漏。全图检测可以利用多尺度特征漏检率更低。它的输出跟车辆检测类似只是没有类别维度通常输出[1, N, 4]加[1, N]置信度或者合并成[1, N, 5]。车牌检测模型的输入尺寸常见也是 640x640和车辆检测共用一个预处理流程。由于小目标天然分数偏低置信度阈值不要设太高我一般设 0.3 左右。如果你发现路口的车牌频繁漏检可以把输入尺寸提到 768 试试代价是推理时间变长。源码里拿到车牌框后一般会在原图上再画一个黄色框如果你需要识别车牌字符这份源码不负责需要额外接 OCR比如 PaddleOCR 转 Onnx但那是另一个工程了。3. C# 中把 Onnx 模型跑起来加载、预处理、后处理三段落地写法这一章是真正能抄作业的部分。工程骨架大概是一个VehiclePipeline类内部持有三个 InferenceSession对外暴露一个Analyze(Mat bgr)方法。我用的是 Microsoft.ML.OnnxRuntime 和 OpenCvSharp4这也是 C# 上位机里最常见的搭配。如果你已经有 OnnxRuntime 基础重点看预处理和输出解析就行。3.1 工程引用和模型准备OnnxRuntime 与 OpenCvSharp 的版本搭配先建一个 .NET 6/8 的类库或控制台项目平台目标设为 x64。OnnxRuntime 的 NuGet 包用Microsoft.ML.OnnxRuntime图像处理用OpenCvSharp4和OpenCvSharp4.runtime.win。这两个包如果版本不匹配运行时会报 DllNotFoundException解决方式是统一更新到同一批版本。// VehiclePipeline.cs 文件头部引用 using System; using System.Collections.Generic; using System.Linq; using Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime.Tensors; using OpenCvSharp;代码逻辑说明这些命名空间覆盖了推理会话、张量创建和 OpenCvSharp 的 Mat 操作。注意不要让引用列表里同时混入 System.DrawingMat 和 Bitmap 互转容易出错。我一般只在最后画框时用 Cv2 的接口避免类型混乱。模型文件放哪个目录也值得注意。Debug 和 Release 输出目录会覆盖最好把三个.onnx文件拷到models/子目录并在代码里用AppContext.BaseDirectory拼路径而不是写死绝对路径。常见做法是构造函数接收模型路径这样测试和上线部署都能复用。3.2 车辆检测推理从 Mat 到检测框的完整函数车辆检测是整个管线的入口。下面这个函数实现了 letterbox、张量转换、输出解析和 NMS 的完整流程。public class VehiclePipeline { private readonly InferenceSession _detSession; public VehiclePipeline(string detModelPath) { _detSession new InferenceSession(detModelPath); // 打印输入输出信息便于确认张量名 foreach (var kv in _detSession.InputMetadata) Console.WriteLine($Input: {kv.Key}, dims: {string.Join(,, kv.Value.Dimensions)}); } public Listfloat[] DetectVehicles(Mat bgr, out float scale, out float padX, out float padY) { int inputSize 640; using var rgb new Mat(); Cv2.CvtColor(bgr, rgb, ColorConversionCodes.BGR2RGB); // letterbox等比缩放 灰色 padding var (resized, scale, padX, padY) Letterbox(rgb, inputSize); var tensor ConvertToTensor(resized, inputSize); var inputs new ListNamedOnnxValue { NamedOnnxValue.CreateFromTensor(image, tensor) }; using var results _detSession.Run(inputs); // 取第一个输出常见 [1, N, 6]最后一维是 x1,y1,x2,y2,score,label var output results.First().AsTensorfloat(); var boxes new Listfloat[](); int rows output.Length / 6; for (int i 0; i rows; i) { float x1 output[0, i, 0]; float y1 output[0, i, 1]; float x2 output[0, i, 2]; float y2 output[0, i, 3]; float score output[0, i, 4]; if (score 0.5f) continue; // 归一化坐标映射回原图 float origX1 (x1 * inputSize - padX) / scale; float origY1 (y1 * inputSize - padY) / scale; float origX2 (x2 * inputSize - padX) / scale; float origY2 (y2 * inputSize - padY) / scale; boxes.Add(new float[] { origX1, origY1, origX2, origY2, score }); } return NMS(boxes, 0.45f); } }逻辑说明Letterbox返回缩放比例和 padding 偏移后处理时所有坐标关键落在这一句映射上。如果没有这一步检测框会整体向左上偏移。输出解析前先打印InputMetadata能帮你确认输入张量名到底是image还是images不同导出工具命名不一样。参数说明0.5f是置信度阈值0.45f是 NMS IoU 阈值。实时监控场景建议把置信度降到 0.4避免漏检夜间车辆离线分析可以拉到 0.6 减少误报。ConvertToTensor内部做了 BGR 转 RGB、缩放、归一化到[0,1]和 HWC 到 CHW 的变换这一步不能省。3.3 车型和颜色分类裁剪车辆框走分类分支属性分类的输入是车辆框裁剪出来的小图代码比检测简单但要注意每个模型有自己的输入尺寸。public string ClassifyAttribute(Mat bgr, Rect carRect, string attrType) { using var car new Mat(bgr, carRect); using var resized new Mat(); Cv2.Resize(car, resized, new OpenCvSharp.Size(224, 224)); var tensor ConvertToTensor(resized, 224); var session attrType color ? _colorSession : _typeSession; using var results session.Run(new ListNamedOnnxValue { NamedOnnxValue.CreateFromTensor(input, tensor) }); var logits results.First().AsTensorfloat(); float[] probs Softmax(logits); int maxId ArgMax(probs); float maxProb probs[maxId]; // 置信度阈值低于阈值返回 unknown if (maxProb 0.6f) return unknown; return attrType color ? ColorLabels[maxId] : TypeLabels[maxId]; }逻辑说明Mat(bgr, carRect)是 OpenCvSharp 的裁剪方式carRect来自检测框。这里没有再画 letterbox因为属性模型训练时对长宽比不敏感直接 resize 到 224x224 即可。Softmax和ArgMax是两个手写函数源码里都有不建议用循环外的库几十行代码能解决的事没必要引依赖。参数说明置信度阈值 0.6 是经验值。白天光线好可以保持晚上建议降到 0.45否则大量结果都会变成 unknown。ColorLabels和TypeLabels是字符串数组顺序必须和模型训练时的类别顺序一致如果标签错位颜色识别会一直报错这属于静默错误调试时很难发现。3.4 车牌检测与结果合并让三路结果共用一套坐标车牌检测和车辆检测的预处理几乎一样区别在于置信度阈值和输出解析。public ListRect DetectPlates(Mat bgr) { int plateSize 640; using var rgb new Mat(); Cv2.CvtColor(bgr, rgb, ColorConversionCodes.BGR2RGB); var (resized, scale, padX, padY) Letterbox(rgb, plateSize); var tensor ConvertToTensor(resized, plateSize); using var results _plateSession.Run(new ListNamedOnnxValue { NamedOnnxValue.CreateFromTensor(image, tensor) }); var output results.First().AsTensorfloat(); var plates new ListRect(); int rows output.Length / 5; for (int i 0; i rows; i) { float score output[0, i, 4]; if (score 0.3f) continue; int x1 (int)((output[0, i, 0] * plateSize - padX) / scale); int y1 (int)((output[0, i, 1] * plateSize - padY) / scale); int x2 (int)((output[0, i, 2] * plateSize - padX) / scale); int y2 (int)((output[0, i, 3] * plateSize - padY) / scale); plates.Add(new Rect(new Point(x1, y1), new Point(x2, y2))); } return plates; }逻辑说明车牌输出是[1, N, 5]第 5 位是置信度没有类别。解析逻辑和车辆检测几乎一样区别只是阈值。合并三路结果时车辆检测框和车牌框并没有一一绑定关系我一般先画出所有框再按面积重叠度把车牌和车辆关联起来。如果一个车牌框落在某个车辆框内就把车牌信息挂到该车辆上。参数说明0.3f是小目标检测的常用阈值因为车牌特征小置信度天然偏低。如果你在高速场景下漏检多把plateSize调到 768 比调阈值更有效。代价是推理时间约增加一倍这个需要在实时性和召回率之间自己权衡。4. 避坑与排查从推理结果玄学到性能瓶颈的五个真实记录Onnx 部署最常见的翻车点不在模型本身而在预处理和运行环境。这一章我按自己踩过的顺序写了五条记录每一条都是现象、原因、解决三步照着检查能省下一个下午。4.1 检测框总是错位letterbox 和归一化必须和训练一致现象检测框整体向左上偏移或者框比车小一圈。换一辆车测试还是一样不是随机错误。原因我把图片直接 resize 到 640x640没有做 letterbox。模型训练时用的是等比缩放加灰色 padding输入图像内容在张量里的位置和你喂进去的不一样坐标映射自然错位。解决严格复现训练时的预处理。在Letterbox函数里记录scale、padX、padY后处理时用(x * inputSize - padX) / scale还原坐标。同时确认归一化方式Paddle 系列模型通常除以 255Onnx 模型如果 TensorRT 转换过可能是减均值除方差打印模型输入元数据时能看到具体预处理要求。4.2 CPU 推理慢到没法用先看输入尺寸和线程数现象单帧车辆检测加属性推理花了 700ms完全达不到实时。项目要求 10fps 以上。原因默认 SessionOptions 没有配置线程输入尺寸一直是 640碰到 4 辆车时属性模型要跑 8 次累计开销很大。OnnxRuntime 在 CPU 上的默认优化并不激进。解决在SessionOptions里设置IntraOpNumThreads 4然后把检测输入从 640 降到 320属性输入降到 192。实测 320 尺寸在 1080P 摄像头下仍能检出大部分车辆单帧能压在 150ms 以内。如果还嫌慢再走 Onnx 静态量化转 int8这就是热词里常说的.onnx 量化 int8。量化后模型体积小一半CPU 推理可能快一倍但小目标车型颜色会掉点需要拿一批真实图片验证。4.3 颜色识别在夜间全变黑低置信度不设阈值的坑现象晚上十点拍的车颜色识别结果全是黑色或深灰色白天正常。跑一帧车灯关闭的车也这样。原因属性模型输出的概率经过 Softmax 后最大概率可能只有 0.3但ArgMax照样返回一个索引。颜色类别里黑色和深灰色在夜间特征相似低置信度时模型很容易偏向深色系。解决给属性分类加置信度阈值低于 0.6 返回 unknown。更稳的做法是像这句一样记录最近 N 帧的颜色结果取出现次数最多的标签而不是每帧都覆盖。夜间场景可以额外做一次亮度均衡但那样会增加耗时我一般先塞阈值能解决大半问题。4.4 OnnxRuntime 偶尔报 AccessViolation多线程 Session 共享的坑现象上位机开了 4 个线程同时跑车辆分析运行几小时后偶发AccessViolationException事件日志里是c0000005代码里根本没有指针操作。原因OnnxRuntime 的 InferenceSession 在底层调用了 C 推理库跨线程共享同一个 Session 时部分版本的资源访问竞争会触发访问违规。C# 里的lock能避免同时 Run但会把并发拖成串行。解决我的习惯是每个工作线程创建独立的InferenceSession模型路径相同直接 new 一个实例即可。内存开销不大因为底层的模型 buffer 通常只加载一次管理成本低于共享锁。如果你坚持共享一个 Session至少要在调用Run的位置加lock并在多线程压力下跑连续 8 小时验证稳定性。4.5 车牌检测漏检小目标阈值和输入分辨率要一起调现象路口摄像头拍到的车辆都能检测出来但车牌框时有时无尤其距离 20 米以上的车几乎捡不到。原因车牌在整图里占的像素太少640x640 输入下可能只有 20x10 像素。模型能学到的特征本来就有限置信度阈值 0.3 对远距离目标还是偏高。解决先把阈值降到 0.25 试如果漏检率还是不达标把车牌检测输入改到 768 甚至 960。这个模型只吃整图分辨率调高一档小目标特征保留明显增加。注意车牌框有角度倾斜时Rectangle会覆盖不全可以在车牌框四周各 expand 5% 的像素给后续 OCR 留出余量。5. 把管线串成实时任务ROI 过滤、结果 JSON 与验证习惯三个模型都跑通后下一步是让它们在上位机里稳定输出。我常用一个简单技巧给画面加 ROI 区域只分析检测框中心点落在 ROI 内的车辆。这样可以把画面外道路、树影里的误检直接滤掉给后端省不少事。public string BuildResultJson(Mat bgr, Rect roi) { var vehicles DetectVehicles(bgr, out _, out _, out _); var list new Listobject(); foreach (var v in vehicles) { var center new Point((int)((v[0] v[2]) / 2), (int)((v[1] v[3]) / 2)); if (!roi.Contains(center)) continue; var color ClassifyAttribute(bgr, RectFromArray(v), color); var type ClassifyAttribute(bgr, RectFromArray(v), type); list.Add(new { bbox v, color, type }); } return System.Text.Json.JsonSerializer.Serialize(list); }这段代码不复杂工程价值在于把结果结构化。上位机拿到 JSON 后写数据库、接大屏、联动道闸都方便。注意RectFromArray需要把 float 数组转成Rect并做边界裁剪避免裁剪区域超出图像范围导致 Cv2 抛异常。验证方法的习惯我坚持了很久每次换模型或改预处理参数先用三张有代表性的图片过一遍一张逆光、一张夜间、一张远距离小目标对比坐标误差和类别输出然后再跑视频流。从那以后我每次拿到新的 Onnx 模型都强制走一遍“打印输入输出张量 - 单图验证 - 视频流压测”这三步再也不会在部署现场被玄学问题拖半天。希望帮到你。本文还有配套的精品资源点击获取