ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

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

简介:本资源为基于 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 weights=output/ppyoloe_vehicle/model_final.pdparams \ --output_dir=inference_model \ --export_onnx=True \ --opset_version=11 \ --input_shape=1,3,640,640

这段命令的核心是三个参数:--export_onnx=True打开 Onnx 导出,--opset_version=11锁定算子集版本,--input_shape=1,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 weights=output/vehicle_type/model_final.pdparams \ --output_dir=inference_model_type \ --export_onnx=True \ --opset_version=11 \ --input_shape=1,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 weights=output/vehicle_plate/model_final.pdparams \ --output_dir=inference_model_plate \ --export_onnx=True \ --opset_version=11 \ --input_shape=1,3,480,480

导出后要检查输出是否包含车牌的四点坐标还是矩形框。PP-Vehicle 的车牌检测通常输出矩形框,但有些版本会输出四个角点用于透视矫正。C# 端后处理要按输出维度来写,如果是 1xNx4 就是矩形框,如果是 1xNx8 就是四点。这个差异如果不提前确认,代码写到最后会发现框画不出来。

3. C# 端 OnnxRuntime 推理封装:从会话创建到张量取回

3.1 安装 OnnxRuntime 与创建推理会话

C# 端用 Microsoft.ML.OnnxRuntime 这个 NuGet 包,版本我一般选 1.16 或 1.17,再高有些 API 变动。安装命令:

# 在项目目录下安装 OnnxRuntime,CPU 版本 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 DenseTensor<float> Preprocess(Bitmap bitmap, int width, int height) { // 缩放到模型输入尺寸,双线性插值 using var resized = new Bitmap(bitmap, new Size(width, height)); var tensor = new DenseTensor<float>(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 List<Rect> DetectVehicles(Bitmap image, float confThreshold = 0.5f, float nmsThreshold = 0.45f) { var input = Preprocess(image, 640, 640); var inputs = new List<NamedOnnxValue> { NamedOnnxValue.CreateFromTensor("image", input) }; using var results = _detSession.Run(inputs); // 假设第一个输出是 1xNx4 的框,第二个是 1xN 的分数 var boxes = results.First(r => r.Name.Contains("box")).AsTensor<float>(); var scores = results.First(r => r.Name.Contains("score")).AsTensor<float>(); 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 List<NamedOnnxValue> { NamedOnnxValue.CreateFromTensor("input", input) }; using var results = _typeSession.Run(inputs); var output = results.First().AsTensor<float>(); // 找最大概率的类别 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 List<Rect> 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(List<Bitmap> crops) { int n = crops.Count; var batchTensor = new DenseTensor<float>(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和std,C# 端严格对齐。通道顺序可以用一张纯色图测试,如果红色图送进去检测结果异常,就是通道反了。

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_logits

C# 端取两个输出分别做 argmax 即可。但要注意,合并后的模型精度通常比两个独立模型低 1 到 3 个百分点,因为两个任务的特征需求不完全一致。如果业务对精度敏感,还是分开跑。

车牌检测和车辆检测的合并更难,因为车牌是小目标,车辆是大目标,同一个特征金字塔很难同时兼顾。我试过用 YOLO 的多类检测,把车辆和车牌作为两个类别一起检测,结果车牌召回率掉了 8 个百分点。后来改成在车辆检测的基础上,对每个车辆框的下半部分单独跑车牌检测,召回率才回来。所以我的建议是:车型和颜色可以合并,车牌检测保持独立。

验证合并效果的方法很简单:准备 100 张有标注的图,分别跑合并前和合并后的流程,统计车型准确率、颜色准确率、车牌召回率、单帧耗时四个指标。如果准确率下降在 2 个百分点以内,耗时下降超过 20%,就值得合并;否则保持原样。

最后说一个我踩过的坑:OnnxRuntime 的 GPU 版本在 C# 里部署时,CUDA 和 cuDNN 的版本必须和 OnnxRuntime 官方文档里写的一致,差一个小版本就可能加载失败。我一般会在项目里锁定 OnnxRuntime 版本,不随便升级。CPU 版本虽然慢,但部署省心,如果延迟要求不是特别苛刻,CPU 版本反而是更稳的选择。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进