资讯动态

C# 集成 OnnxRuntime 部署 PP-Vehicle 车辆分析全流程

发布时间:2026/10/19 0:31:42 来源:尧图企业网站定制
简介本资源为基于 C# 与 Onnx 的 PP-Vehicle 车辆分析完整源码面向具备一定 C# 与深度学习部署基础、希望快速落地车辆视觉分析功能的开发者。项目整合车辆检测、车型识别、车辆颜色判别与车牌检测等多项能力可用于智能交通、停车场管理、车辆卡口等场景的二次开发与学习研究。压缩包共 138 个文件约 53.65MB包含 14 个 cs 源码文件、10 个 config 配置、10 个 dll 依赖、9 个 exe 可执行程序、2 个 onnx 模型以及 resx、resources 等资源文件覆盖从模型推理到界面展示的完整工程结构。目前已有 329 人学习下载。读者可获取可直接编译运行的工程源码理解 PP-Vehicle 系列模型在 C# 环境下的调用方式掌握多任务车辆分析的推理流程与参数配置并借助现成模型与依赖快速验证效果为后续功能扩展与性能优化提供参考。1. 从一张路口抓拍图说起C# 跑通 PP-Vehicle 车辆分析到底难在哪路口抓拍图里同时有七八辆车每辆车的车型、颜色、车牌位置都要在一帧内给出结果这是很多做智慧交通、停车场管理、车辆档案系统的开发者绕不开的需求。PP-Vehicle 是飞桨车辆分析套件里的一条完整流水线覆盖车辆检测、车型识别、车辆颜色识别、车牌检测四个子任务。把它落到 C# 工程里靠的是 Onnx 模型加 OnnxRuntime 推理而不是 Python 端那套 PaddleInference。标题里这几个词拆开看C# 是宿主语言Onnx 是模型交换格式PP-Vehicle 是任务集合源码意味着这套东西要能直接编译、直接换模型、直接接业务。我见过太多人卡在第一步Python 里跑得好好的模型导出 Onnx 之后在 C# 里要么输出对不上要么后处理写错要么车牌框飘得离谱。这篇笔记就按我实际落地的顺序把模型从哪来、C# 怎么接、四个子任务怎么串、参数怎么调、坑在哪一条条讲清楚。适合已经会 C# 但没碰过 Onnx 推理的人也适合做过 Python 车辆分析、想迁到桌面端或服务端的熟手。2. PP-Vehicle 四个子任务的模型选型与 Onnx 导出2.1 为什么不是一个大模型端到端而是四个模型串起来PP-Vehicle 在飞桨里的原始设计就是多模型流水线不是单个端到端网络。车辆检测用 PP-YOLOE 系列或者 RT-DETR 做基座输出所有车的边界框车型识别和颜色识别各是一个独立的分类模型输入是检测框裁出来的车辆小图车牌检测又是一个独立的小目标检测模型输入同样是车辆区域。这种拆法的好处是每个子任务可以单独换模型、单独调阈值坏处是推理次数变多一帧里如果有 N 辆车实际推理次数是 1 N N N。我一般会先确认业务对延迟的容忍度。如果是离线视频分析四模型串行完全够用如果是实时流就要考虑把车型和颜色合并成一个多任务分类头或者把车牌检测和车辆检测合并到一个模型里做多类检测。但合并会牺牲精度尤其是车牌这种小目标合并后召回率通常掉 5 到 10 个百分点。所以第一版我建议老老实实按四个模型来先把链路跑通再谈优化。模型来源上飞桨官方在 PaddleDetection 和 PaddleClas 里提供了预训练权重导出 Onnx 的脚本在工具链里都有。导出时要注意 opset 版本我一般用 opset 11 或 12再高有些算子 OnnxRuntime 的 C# 包支持不全。输入尺寸也要固定车辆检测常用 640x640车型和颜色分类常用 224x224车牌检测常用 320x320 或 480x480。动态轴能不用就不用C# 端处理动态 shape 很麻烦。2.2 导出 Onnx 的具体命令与三个必查项导出这一步在 Python 环境里做但产物是给 C# 用的所以参数要按 C# 端的能力来定。下面是我常用的导出命令模板以车辆检测模型为例# 在 PaddleDetection 目录下执行导出 PP-YOLOE 车辆检测模型为 Onnx python tools/export_model.py \ -c configs/ppyoloe/ppyoloe_crn_l_300e_coco.yml \ -o weightsoutput/ppyoloe_vehicle/model_final.pdparams \ --output_dirinference_model \ --export_onnxTrue \ --opset_version11 \ --input_shape1,3,640,640这段命令的核心是三个参数--export_onnxTrue打开 Onnx 导出--opset_version11锁定算子集版本--input_shape1,3,640,640固定输入维度。导出完成后我必查三件事第一用 Netron 打开 Onnx 文件确认输入名和输出名C# 里要按名字取张量第二确认输出节点数量PP-YOLOE 导出后通常有多个输出头后处理要按顺序拼第三用 Python 的 onnxruntime 跑一张测试图把输出 shape 打印出来和 C# 端对齐。车型和颜色分类模型的导出更简单因为它们本质是分类网络# 导出车型分类模型输入 224x224 python tools/export_model.py \ -c configs/vehicle_attribute/vehicle_attribute.yml \ -o weightsoutput/vehicle_type/model_final.pdparams \ --output_dirinference_model_type \ --export_onnxTrue \ --opset_version11 \ --input_shape1,3,224,224这里有个容易翻车的点分类模型的输出通常是 softmax 之后的概率但有些导出脚本会保留 logits。C# 端如果直接取 argmax两者结果一样但如果要拿置信度做阈值过滤就必须确认输出是否已经过 softmax。我的习惯是在导出后用一张已知类别的图跑一遍看输出值是否在 0 到 1 之间且和为 1如果不是就在 C# 里补一个 softmax。2.3 车牌检测模型的特殊性小目标与输入分辨率车牌检测是四个任务里最挑参数的。车牌在整张图里占比很小如果车辆检测框裁得不够准车牌检测的输入就已经损失了信息。我一般会把车牌检测的输入分辨率设得比车型分类高常用 480x480 或 640x640而不是 224。导出时输入 shape 要相应调整# 车牌检测模型导出输入 480x480 python tools/export_model.py \ -c configs/vehicle_plate/vehicle_plate_detection.yml \ -o weightsoutput/vehicle_plate/model_final.pdparams \ --output_dirinference_model_plate \ --export_onnxTrue \ --opset_version11 \ --input_shape1,3,480,480导出后要检查输出是否包含车牌的四点坐标还是矩形框。PP-Vehicle 的车牌检测通常输出矩形框但有些版本会输出四个角点用于透视矫正。C# 端后处理要按输出维度来写如果是 1xNx4 就是矩形框如果是 1xNx8 就是四点。这个差异如果不提前确认代码写到最后会发现框画不出来。3. C# 端 OnnxRuntime 推理封装从会话创建到张量取回3.1 安装 OnnxRuntime 与创建推理会话C# 端用 Microsoft.ML.OnnxRuntime 这个 NuGet 包版本我一般选 1.16 或 1.17再高有些 API 变动。安装命令# 在项目目录下安装 OnnxRuntimeCPU 版本 dotnet add package Microsoft.ML.OnnxRuntime --version 1.16.3 # 如果需要 GPU 推理换 GPU 包 dotnet add package Microsoft.ML.OnnxRuntime.Gpu --version 1.16.3创建会话时我习惯把四个模型分别加载成四个 InferenceSession 对象而不是每次推理都新建。会话创建是重操作复用能省不少时间。下面是一个封装类的骨架using Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime.Tensors; public class VehicleAnalyzer : IDisposable { private readonly InferenceSession _detSession; private readonly InferenceSession _typeSession; private readonly InferenceSession _colorSession; private readonly InferenceSession _plateSession; public VehicleAnalyzer(string detPath, string typePath, string colorPath, string platePath) { // 车辆检测会话CPU 推理线程数按核数设 var detOptions new SessionOptions(); detOptions.IntraOpNumThreads Environment.ProcessorCount; detOptions.GraphOptimizationLevel GraphOptimizationLevel.ORT_ENABLE_ALL; _detSession new InferenceSession(detPath, detOptions); // 车型、颜色、车牌会话同理可以共用一套 options _typeSession new InferenceSession(typePath, detOptions); _colorSession new InferenceSession(colorPath, detOptions); _plateSession new InferenceSession(platePath, detOptions); } public void Dispose() { _detSession?.Dispose(); _typeSession?.Dispose(); _colorSession?.Dispose(); _plateSession?.Dispose(); } }IntraOpNumThreads设成 CPU 核数GraphOptimizationLevel开到 ALL这两个参数对 CPU 推理速度影响明显。如果用的是 GPU 包还要加AppendExecutionProvider_CUDA但要注意 CUDA 版本和 OnnxRuntime 版本的对应关系版本不匹配会直接抛异常。3.2 图像预处理从 Bitmap 到张量的完整转换C# 里读图常用 System.Drawing 的 Bitmap但 OnnxRuntime 要的是 DenseTensor 。预处理包括缩放、归一化、通道转换三步。我一般写一个通用方法public static DenseTensorfloat Preprocess(Bitmap bitmap, int width, int height) { // 缩放到模型输入尺寸双线性插值 using var resized new Bitmap(bitmap, new Size(width, height)); var tensor new DenseTensorfloat(new[] { 1, 3, height, width }); for (int y 0; y height; y) { for (int x 0; x width; x) { var pixel resized.GetPixel(x, y); // PP-Vehicle 系列模型用 ImageNet 均值方差归一化 tensor[0, 0, y, x] (pixel.R / 255f - 0.485f) / 0.229f; tensor[0, 1, y, x] (pixel.G / 255f - 0.456f) / 0.224f; tensor[0, 2, y, x] (pixel.B / 255f - 0.406f) / 0.225f; } } return tensor; }这里三个通道的顺序是 RGB因为 PP-Vehicle 训练时用的是 RGB。如果导出模型时用了 BGR这里就要换。归一化的均值和方差是 ImageNet 的标准值PP-Vehicle 系列基本都用这套。GetPixel在循环里调用性能很差生产环境建议用LockBits或者 ImageSharp 的ProcessPixelRows但作为第一版能跑通的代码这样写最直观。3.3 推理执行与输出解析车辆检测的后处理车辆检测的输出是多个张量PP-YOLOE 导出后通常包含框坐标、类别分数、置信度。后处理要做的是解码框、做 NMS、过滤低分。下面是一个简化版的后处理public ListRect DetectVehicles(Bitmap image, float confThreshold 0.5f, float nmsThreshold 0.45f) { var input Preprocess(image, 640, 640); var inputs new ListNamedOnnxValue { NamedOnnxValue.CreateFromTensor(image, input) }; using var results _detSession.Run(inputs); // 假设第一个输出是 1xNx4 的框第二个是 1xN 的分数 var boxes results.First(r r.Name.Contains(box)).AsTensorfloat(); var scores results.First(r r.Name.Contains(score)).AsTensorfloat(); var candidates new List(Rect rect, float score)(); for (int i 0; i scores.Dimensions[1]; i) { float score scores[0, i]; if (score confThreshold) continue; // 框坐标是归一化的 cxcywh要转成 xyxy 再乘原图尺寸 float cx boxes[0, i, 0] * image.Width; float cy boxes[0, i, 1] * image.Height; float w boxes[0, i, 2] * image.Width; float h boxes[0, i, 3] * image.Height; var rect new Rect((int)(cx - w / 2), (int)(cy - h / 2), (int)w, (int)h); candidates.Add((rect, score)); } // NMS 去重 return Nms(candidates, nmsThreshold); }输出名里的box和score是模糊匹配实际名字要用 Netron 看。框的格式是归一化的中心点加宽高这是 PP-YOLOE 的标准输出。如果导出时用了别的检测头格式可能不同一定要用 Python 端跑一遍对比数值。NMS 的实现网上很多这里不展开但要注意 IoU 阈值设 0.45 到 0.5 之间太低会漏检相邻车辆太高会保留重复框。4. 车型、颜色、车牌三个子任务的串联与参数调优4.1 从车辆框裁图到分类模型输入车辆检测拿到框之后车型和颜色分类的输入就是裁出来的车辆小图。这里有个细节裁图时要不要留边距。我的经验是留 10% 到 15% 的边距因为检测框有时会切掉车头或车尾留边距能让分类模型看到更完整的车辆。裁图后缩放到 224x224再做同样的归一化。public (string type, float typeConf) ClassifyType(Bitmap vehicleCrop) { var input Preprocess(vehicleCrop, 224, 224); var inputs new ListNamedOnnxValue { NamedOnnxValue.CreateFromTensor(input, input) }; using var results _typeSession.Run(inputs); var output results.First().AsTensorfloat(); // 找最大概率的类别 int maxIdx 0; float maxVal output[0, 0]; for (int i 1; i output.Dimensions[1]; i) { if (output[0, i] maxVal) { maxVal output[0, i]; maxIdx i; } } // 类别名要按训练时的 label 列表映射 return (TypeLabels[maxIdx], maxVal); }TypeLabels是训练时的类别名列表顺序不能错。如果导出模型时没有附带 label 文件就要从训练配置里找。颜色分类同理只是换一个会话和 label 列表。两个分类模型的置信度阈值我一般设 0.6低于这个值就返回“未知”而不是硬给一个类别。4.2 车牌检测的输入选择整车图还是车辆裁图车牌检测的输入有两种选择直接送整车图或者送车辆裁图。直接送整车图的问题是车牌在 640x640 里可能只有几十个像素检测效果差送车辆裁图的问题是如果车辆检测框不准车牌可能被裁掉。我的做法是两者都跑取置信度高的那个。实际测试下来车辆裁图加 480x480 输入的组合车牌召回率比整车图高 15% 左右。public ListRect DetectPlate(Bitmap vehicleCrop, Bitmap fullImage) { // 先用车牌裁图跑一次 var cropResult RunPlateSession(vehicleCrop, 480, 480); // 再用整车图跑一次 var fullResult RunPlateSession(fullImage, 640, 640); // 合并结果按置信度排序做一次 NMS var merged cropResult.Concat(fullResult).ToList(); return Nms(merged, 0.4f); }车牌检测的 NMS 阈值要比车辆检测低因为车牌框通常不会重叠阈值设 0.4 能去掉重复框又不误删。车牌框的坐标如果是相对裁图的要映射回原图坐标系这一步容易漏漏了就会画错位置。4.3 四个任务的串行调度与批量优化一帧图进来完整流程是车辆检测 → 对每个车辆框裁图 → 车型分类 → 颜色分类 → 车牌检测。如果一帧有 10 辆车就是 1 次检测加 30 次分类/检测推理。CPU 上这个耗时可能在 200 到 500 毫秒实时性要求高的话要优化。我常用的优化手段有三个第一把车型和颜色分类合并成一个模型输出两个头这样每辆车少一次推理第二车牌检测只在车辆框的下半部分跑因为车牌基本在车辆下半部裁掉上半部分能减少输入尺寸第三用批处理把多辆车的裁图拼成一个 batch 送进分类模型OnnxRuntime 支持动态 batch但导出时要允许 batch 轴动态。// 批处理示例把多辆车的裁图拼成 1xNx3x224x224 public List(string type, float conf) BatchClassifyType(ListBitmap crops) { int n crops.Count; var batchTensor new DenseTensorfloat(new[] { n, 3, 224, 224 }); for (int i 0; i n; i) { var single Preprocess(crops[i], 224, 224); for (int c 0; c 3; c) for (int y 0; y 224; y) for (int x 0; x 224; x) batchTensor[i, c, y, x] single[0, c, y, x]; } // 送进会话输出是 Nxnum_classes // ... }批处理能明显提升吞吐但要注意显存或内存占用。CPU 上 batch 设 4 到 8 比较合适GPU 上可以到 16 或 32。5. 避坑与排查C# Onnx 车辆分析里最容易翻车的五件事5.1 输出张量名字对不上Run 直接抛异常现象_detSession.Run(inputs)抛InvalidArgument提示找不到输入名或输出名。原因导出 Onnx 时输入名可能是image、input、x不同模型不一样C# 里硬编码的名字和实际不符。解决用 Netron 打开 Onnx 文件看输入输出节点的名字或者用代码打印_detSession.InputMetadata.Keys和OutputMetadata.Keys按实际名字取。5.2 预处理归一化参数用错检测框全部偏移现象模型能跑但框的位置整体偏移或者分数极低。原因PP-Vehicle 训练时用的均值和方差是 ImageNet 的但有些人导出时用了 0.5/0.5 的归一化或者通道顺序从 RGB 变成了 BGR。解决确认训练配置里的mean和stdC# 端严格对齐。通道顺序可以用一张纯色图测试如果红色图送进去检测结果异常就是通道反了。5.3 车牌框坐标没映射回原图画出来飘在天上现象车牌检测有结果但画在原图上位置完全不对。原因车牌检测是在车辆裁图上跑的输出坐标是相对裁图的直接画到原图就错了。解决裁图时记录裁图区域在原图中的偏移(offsetX, offsetY)车牌框坐标加上偏移再画。如果裁图还做了缩放要先除以缩放比例再加偏移。5.4 分类模型输出没做 softmax置信度全是负数现象车型分类的置信度是负数或者大于 1。原因导出模型输出的是 logits不是概率。解决在 C# 里补一个 softmax或者导出时确认模型末尾有 softmax 层。判断方法很简单把输出值打印出来如果和不为 1 且范围不在 0 到 1就是 logits。5.5 多线程调用同一个 InferenceSession 导致崩溃现象单线程跑正常多线程并发调用同一个会话时程序崩溃或结果错乱。原因InferenceSession 的 Run 方法不是线程安全的多个线程同时调用会出问题。解决每个线程创建独立的 InferenceSession或者用锁串行化调用。如果并发量高建议用会话池每个线程从池里取一个会话。6. 把四个模型压成一个多任务头的尝试与边界四个模型串行跑通之后下一步自然是想着能不能合并。我试过把车型和颜色合并成一个多任务分类模型共享 backbone两个分类头分别输出车型和颜色。这样做的好处是每辆车少一次推理实测在 CPU 上能省 30% 左右的时间。合并的方法是在训练时定义两个损失函数导出 Onnx 时两个头都作为输出节点。# 多任务分类模型导出时的输出定义示意 # 共享 backbone两个全连接头分别输出车型和颜色 # 导出后 Onnx 有两个输出type_logits 和 color_logitsC# 端取两个输出分别做 argmax 即可。但要注意合并后的模型精度通常比两个独立模型低 1 到 3 个百分点因为两个任务的特征需求不完全一致。如果业务对精度敏感还是分开跑。车牌检测和车辆检测的合并更难因为车牌是小目标车辆是大目标同一个特征金字塔很难同时兼顾。我试过用 YOLO 的多类检测把车辆和车牌作为两个类别一起检测结果车牌召回率掉了 8 个百分点。后来改成在车辆检测的基础上对每个车辆框的下半部分单独跑车牌检测召回率才回来。所以我的建议是车型和颜色可以合并车牌检测保持独立。验证合并效果的方法很简单准备 100 张有标注的图分别跑合并前和合并后的流程统计车型准确率、颜色准确率、车牌召回率、单帧耗时四个指标。如果准确率下降在 2 个百分点以内耗时下降超过 20%就值得合并否则保持原样。最后说一个我踩过的坑OnnxRuntime 的 GPU 版本在 C# 里部署时CUDA 和 cuDNN 的版本必须和 OnnxRuntime 官方文档里写的一致差一个小版本就可能加载失败。我一般会在项目里锁定 OnnxRuntime 版本不随便升级。CPU 版本虽然慢但部署省心如果延迟要求不是特别苛刻CPU 版本反而是更稳的选择。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑