
简介这份C#源码基于ONNX Runtime封装百度PP-Vehicle模型面向需要在.NET环境落地车辆视觉分析的开发者覆盖车辆检测、车型识别、车身颜色识别与车牌检测四类任务适用于智慧交通、停车场、安防监控等场景。包内共138个文件包含C#工程文件.sln/.csproj/.cs、两个内置ONNX模型、运行所需的DLL与EXE、配置文件与示例图片压缩包大小53.65MB目录结构完整可直接用Visual Studio打开运行无需额外下载模型权重。目前已有325人学习适合具备一定C#基础、想掌握ONNX模型集成方法的开发者。通过源码可重点学习模型加载与预处理、推理结果解析、车型/颜色/车牌信息的可视化标注等关键技术并可根据实际场景替换模型或扩展检测逻辑节省二次开发时间。资源中还带有可直接运行的示例便于边用边学快速验证检测与识别效果。1. C# Onnx PP-Vehicle 车辆分析从“能检测”到“能上报车型颜色车牌”的最后一公里多数人第一次把 PP-Vehicle 这套模型接进 C# 工程时会误以为它就是换壳的 YOLO能框出车就完事。真正把需求摊开看车辆检测只是链条最前端的 20%后面还跟着车型识别、颜色分类、车牌识别三件事对应的是三到四个独立的 ONNX 权重文件、三种不同的输入尺寸、三套不一样的后处理逻辑。C# Onnx PP-Vehicle 车辆分析这套源码的价值在于把这几件事串成一条在桌面端和上位机里能跑通的完整管线从 OnnxRuntime 加载模型开始到输出结构化“车型—颜色—车牌”记录结束。适合正在做 C# 上位机车辆分析、不想从头训练模型、只想把现成能力落到本机的从业者。接下来我会按模型转换、C# 推理、车牌处理、问题排查、生产调优这条路径把能复现的步骤和踩过的坑一起写透。2. 模型转换与检查从 Paddle 到 ONNX先把输入输出钉死在纸上Paddle 生态的模型没法被 OnnxRuntime 直接读取第一步要把 Paddle 格式转成 ONNX。网上流传的 PP-Vehicle 源码包通常自带 Paddle 权重但版本新旧不一导出命令里的模型文件名也常不一样。这一章先说清转换流程和验证方法因为后面 C# 代码里的输入输出节点名、张量形状全部由这一步的结果决定。2.1 为什么选 PP-Vehicle 而不是通用 YOLO多模型组合才是它真正的价值PP-Vehicle 出自 PaddleDetection 的车辆分析示例和单独一个车辆检测模型最大的区别是它把检测、二次分类、文字识别三个层级的模型组合在一起预训练权重覆盖了路面常见车型和车牌场景。四个子模型各有分工检测模型负责找车属性模型负责描述车的颜色和类型车牌检测负责定位号牌区域车牌识别负责把号牌区域转成字符串。子模型典型输入输出含义常见结构车辆检测整图 640×640车框xyxy与置信度PP-YOLO / PicoDet 系列车辆属性单辆车裁剪图 224×224颜色、车型的分类概率PPLCNet / ResNet车牌检测车辆局部图 736×736车牌区域框或概率图DB 文本检测车牌识别车牌裁剪图 48×320字符概率序列CRNN / PP-OCRv3 识别头自己拿通用 YOLO 重做一遍会发现颜色和车型要额外训练两个分类器车牌要再训练一个 OCR 模型数据标注、训练周期都是很重的负担。PP-Vehicle 的预训练覆盖了公开车辆数据集包括 BDD100K 这种常见的车辆检测数据集因此车辆检测这一步基本不用自己攒数据。对于 C# 项目来说这些模型在部署阶段都是现成的 ONNX 文件真正的工作量集中在怎么组织一次完整的推理流水。还有一个容易被忽略的点PP-Vehicle 源码包里的模型不是单文件而是四个权重配合使用。如果你下载的压缩包里只看到一个检测模型那只能算拿到了四分之一的方案还得按后续章节补齐属性模型和车牌模型。2.2 paddle2onnx 导出命令四个子模型一次导齐导出依赖 paddle2onnx 工具在 PaddleDetection 环境里执行。先确认目标目录下是不是已经用tools/export_model.py把训练模型导出成了 inference 格式否则拿到的是 checkpoint里面只有.pdparams和.pdopt没有model.pdmodelpaddle2onnx 无从下手。四个模型导出命令如下# 导出车辆检测模型 paddle2onnx \ --model_dir output/ppyoloe_vehicle/ \ --model_filename model.pdmodel \ --params_filename model.pdiparams \ --save_file ppyoloe_vehicle_640.onnx \ --opset_version 11 \ --enable_onnx_checker True # 导出车辆属性模型 paddle2onnx \ --model_dir output/vehicle_attribute/ \ --model_filename model.pdmodel \ --params_filename model.pdiparams \ --save_file vehicle_attribute.onnx \ --opset_version 11 # 导出车牌检测模型 paddle2onnx \ --model_dir output/plate_det/ \ --model_filename model.pdmodel \ --params_filename model.pdiparams \ --save_file plate_det.onnx \ --opset_version 11 # 导出车牌识别模型 paddle2onnx \ --model_dir output/plate_rec/ \ --model_filename model.pdmodel \ --params_filename model.pdiparams \ --save_file plate_rec.onnx \ --opset_version 11参数含义逐个说明model_dir指向 inference 模型所在目录目录里必须有model.pdmodel和model.pdiparams两个文件save_file是输出的 ONNX 文件名opset_version 11兼容性好无论 OnnxRuntime 跑在 CPU 还是 CUDA EP 都没有额外限制enable_onnx_checker True会在导出后跑一遍 ONNX 规范检查建议保留。如果你手里的源码包文件名不同比如inference.pdmodel把--model_filename换掉即可。导出后用onnxruntime的 Python 侧加载跑一次确认所有文件能正常 out再拷贝进 C# 项目目录。paddle2onnx 不是唯一选择PaddleX 和 X2Paddle 也能做转换但对 Paddle 原生模型来说paddle2onnx 的兼容性最省心。2.3 Netron 验证输入输出C# 里写的节点名全部在这里核对导出完成后的第一件事是用 Netron 打开每个 ONNX 看一眼。这一步不做C# 里CreateFromTensor(image, input)的image很容易写错运行时报 “input name not found” 再回头查就慢了。我通常逐项核对节点信息输入节点的名字和形状。车辆检测模型输入常叫image形状[1,3,640,640]属性模型可能叫input或x形状[1,3,224,224]车牌识别输入一般是[1,3,48,320]附近。输出节点的格式。车辆检测如果导出时没有集成后处理输出是[1,8400,6]这种候选框表格8400 是 640 分辨率下三个尺度特征图格子之和即 80×80 40×40 20×20每一行是一个预测框属性模型的输出是一个[1,C]或两个分类输出车牌检测输出概率图或检测框数组车牌识别输出[1,seq_len,vocab_size]。// 从 Netron 读到的输入名不要想当然叫 input var inputName session.InputMetadata.Keys.First(); Console.WriteLine($input name: {inputName}); // 打印输出节点名称和维度 foreach (var o in session.OutputMetadata) { Console.WriteLine($output name: {o.Key}, dims: {string.Join(x, o.Value.Dimensions)}); }用这段代码在启动时打印 session 元数据能快速发现节点名对不上、输出维度和预期不符的问题。如果对输出到底算“候选框”还是“已经解码过的框”没把握建议先在 Python 侧跑一次 onnxruntime 打印真实输出 shape再回 C# 搭解析逻辑。这一步可以省下后面至少两个小时的调试。3. C# 推理管线搭建OnnxRuntime、OpenCvSharp 与检测框还原模型文件准备齐了接下来是 C# 工程。这一章从依赖选型、图像预处理到检测框解析把推理管线搭起来。四个模型共用一套 OnnxRuntime 核心但每个模型在预处理和后处理上的差异很大组装时最容易出的问题也集中在这里。3.1 依赖与工程结构四个模型四个 SessionC# 端用 .NET 6 或 .NET 8 都可以NuGet 包选Microsoft.ML.OnnxRuntime和OpenCvSharp4后者负责图像读取、缩放、通道拆分。图像库也可以用 SixLabors.ImageSharp但 OpenCvSharp 在 Mat 操作上更贴近 Python 习惯后面做 ROI 裁剪和 CLAHE 增强也更顺手。工程结构我一般这么摆VehicleAnalyzer/ ├── Program.cs ├── Inference/ │ ├── VehicleDetector.cs │ ├── AttributeClassifier.cs │ ├── PlateDetector.cs │ └── PlateOcr.cs ├── Utils/ │ ├── ImagePreprocessor.cs │ └── NmsHelper.cs └── models/ ├── ppyoloe_vehicle_640.onnx ├── vehicle_attribute.onnx ├── plate_det.onnx └── plate_rec.onnx每个推理类内部持有一个独立的InferenceSession这样四个模型互不干扰加载和释放各自管理。InferenceSession构造时会把模型载入内存并附带 EP 选择逻辑会话创建开销比较大不要在每帧都 new而是启动时创建一次后续复用。最小的检测器骨架如下public class VehicleDetector { private readonly InferenceSession _session; private readonly string _inputName; private const int InputSize 640; public VehicleDetector(string modelPath) { _session new InferenceSession(modelPath); _inputName _session.InputMetadata.Keys.Single(); } public void Dispose() _session.Dispose(); }逻辑说明InputMetadata.Keys.Single()读取模型输入节点名避免硬编码image写错InputSize要与导出时的输入尺寸一致如果 ONNX 里是动态形状可以运行时从_session.InputMetadata[_inputName].Dimensions读取。模型文件建议放在输出目录下也就是 csproj 里对.onnx文件设置CopyToOutputDirectory。路径用Path.Combine(AppContext.BaseDirectory, models, xxx.onnx)拼绝对路径不要依赖Environment.CurrentDirectory部署成 Windows 服务或计划任务时当前目录经常和你预期的不是同一个。3.2 图像预处理Letterbox、归一化与通道顺序车辆检测模型训练时的预处理约定和 YOLO 系列一致等比例缩放后补边到正方形再归一化到 0~1。直接用Cv2.Resize强行拉伸到 640×640 也可以跑但宽高比变了会让小目标检出率明显下降尤其是远处的小轿车。实现如下public static float[] LetterboxToTensor(Mat src, int targetSize, out float scale, out int padLeft, out int padTop) { int srcW src.Width, srcH src.Height; scale Math.Min((float)targetSize / srcW, (float)targetSize / srcH); int newW Math.Max(1, (int)Math.Round(srcW * scale)); int newH Math.Max(1, (int)Math.Round(srcH * scale)); padLeft (targetSize - newW) / 2; padTop (targetSize - newH) / 2; using var resized new Mat(); Cv2.Resize(src, resized, new Size(newW, newH)); using var canvas new Mat(new Size(targetSize, targetSize), MatType.CV_32FC3, new Scalar(114, 114, 114)); resized.ConvertTo(resized, MatType.CV_32FC3, 1.0 / 255.0); resized.CopyTo(canvas[new Rect(padLeft, padTop, newW, newH)]); var tensor new float[3 * targetSize * targetSize]; for (int c 0; c 3; c) { using var channel Cv2.ExtractChannel(canvas, c); for (int y 0; y targetSize; y) { var row new float[targetSize]; channel.GetArray(y, 0, row); Array.Copy(row, 0, tensor, c * targetSize * targetSize y * targetSize, targetSize); } } return tensor; }逻辑说明先算等比缩放比例再创建114灰度的画布把缩放后的图贴到画布中央。转 tensor 阶段按 CHW 排布展开Cv2.ExtractChannel每次取一个通道。需要特别注意通道顺序Paddle 模型训练时图像约定是 BGR 还是 RGB要看模型配置。PP-Vehicle 多数情况用的是 RGB而 OpenCV 读进来默认是 BGR所以上面代码在第 0 和第 2 个通道上要做一次交换否则颜色分类会整体跑偏车辆检测则影响不大。推理调用代码var inputTensor new DenseTensorfloat(tensor, new[] { 1, 3, InputSize, InputSize }); var inputs new ListNamedOnnxValue { NamedOnnxValue.CreateFromTensor(_inputName, inputTensor) }; using var outputs _session.Run(inputs);DenseTensor排布顺序是 NCHW和 ONNX 输入完全一致。Run返回结果列表按输出节点名拿值即可。3.3 检测输出解析候选框过滤、坐标还原与 NMS拿到输出后要把[1,8400,6]解析成真实车框。每一行的六列含义是cx, cy, w, h, obj_conf, cls_conf最终置信度取两者相乘。解析和还原的代码如下public ListRect ParseDetections(float[] data, int n, int stride, float scale, int padLeft, int padTop, float scoreThresh) { var boxes new ListRect(); var scores new Listfloat(); for (int i 0; i n; i) { int off i * stride; float objConf data[off 4]; float clsConf data[off 5]; float score objConf * clsConf; if (score scoreThresh) continue; float cx data[off]; float cy data[off 1]; float w data[off 2]; float h data[off 3]; int x1 (int)((cx - w / 2 - padLeft) / scale); int y1 (int)((cy - h / 2 - padTop) / scale); int x2 (int)((cx w / 2 - padLeft) / scale); int y2 (int)((cy h / 2 - padTop) / scale); x1 Math.Clamp(x1, 0, int.MaxValue); y1 Math.Clamp(y1, 0, int.MaxValue); boxes.Add(new Rect(x1, y1, x2 - x1, y2 - y1)); scores.Add(score); } if (boxes.Count 0) return new ListRect(); Cv2.Dnn.NMSBoxes(boxes, scores, scoreThresh, 0.5f, out int[] keepIdx); return keepIdx.Select(idx boxes[idx]).ToList(); }逻辑说明坐标还原公式(cx - w/2 - padLeft) / scale核心是“把 letterbox 坐标系映射回原图坐标”先减中心偏移再减左和上方向的 padding最后除以缩放比例。NMSBoxes是 OpenCvSharp 内置的非极大值抑制传入原始框列表和分数列表输出保留的索引。阈值 0.5 是 IoU如果车辆密集、互相遮挡严重可以降到 0.4如果出现同一辆车被框出两个重叠框则上调到 0.6。属性模型和车牌模型的预处理逻辑不一样属性模型是直接拉伸到 224×224不需要等比缩放补边车牌模型需要配合检测框做 ROI 裁剪后二次处理。所以不同模型不要共用一个预处理函数按任务单独写。4. 车牌检测与车牌识别把“有车牌”升级成“车牌号是多少”车牌处理是 PP-Vehicle 链路里最考验工程经验的一环。车辆检测模型能框出车但车牌在原图里通常只有几十像素宽直接独立跑车牌检测容易漏检跑全图又慢。这一章讲清楚车牌检测策略、OCR 解码和属性识别联动把这部分做成真正能用的输出。4.1 车牌检测策略先找车再在车辆下半区找车牌常见做法是两级检测先全图跑车辆检测拿到车框后在车框下半部分做 ROI 裁剪裁剪图再送车牌检测模型。车牌出现在车头和车尾的保险杠区域取车框底部约一半高度足够覆盖绝大多数场景。ROI 裁剪代码public static Mat CropPlateRoi(Mat fullImage, Rect vehicleBox, double expandRatio 0.2) { int roiH (int)(vehicleBox.Height * 0.55); int roiY vehicleBox.Y vehicleBox.Height - roiH; int roiX vehicleBox.X - (int)(vehicleBox.Width * expandRatio); int roiW vehicleBox.Width (int)(vehicleBox.Width * expandRatio * 2); int x Math.Max(0, roiX); int y Math.Max(0, roiY); int w Math.Min(fullImage.Width - x, roiW); int h Math.Min(fullImage.Height - y, roiH); return new Mat(fullImage, new Rect(x, y, w, h)); }参数说明roiH取车框高度的 55%覆盖保险杠区域expandRatio向外扩 20%是为了避免车辆检测框稍微偏小就切到车牌边缘。卡车、客车这类长车需要把 ROI 比例调成 30%~40%因为它们的车牌高度位置和轿车不一样。低照度场景在送车牌检测前对 ROI 做一次 CLAHE 对比度增强能小幅提升检出率。车牌检测模型的输入尺寸建议用 736×736太小了远端小车的车牌细节丢失太大了推理耗时成倍涨。检测输出如果是概率图而不是框需要先做阈值化再找连通域阈值一般设在 0.3~0.5 之间这个参数直接影响漏检和误检。4.2 车牌裁剪与 OCR从像素到可读字符串的完整链路车牌检测框拿到后从原图上按框裁剪再缩放到 OCR 模型要求的输入。车牌识别模型常见的输入是高 48、宽动态不超过 320 的灰度图或三通道图。裁剪代码public static Mat CropAndResizePlate(Mat fullImage, Rect plateBox, int targetH 48) { int x Math.Clamp(plateBox.X, 0, fullImage.Width - 1); int y Math.Clamp(plateBox.Y, 0, fullImage.Height - 1); int w Math.Clamp(plateBox.Width, 1, fullImage.Width - x); int h Math.Clamp(plateBox.Height, 1, fullImage.Height - y); using var plate new Mat(fullImage, new Rect(x, y, w, h)); double ratio (double)targetH / plate.Height; int targetW Math.Min(320, (int)Math.Round(plate.Width * ratio)); var resized new Mat(); Cv2.Resize(plate, resized, new Size(targetW, targetH)); return resized; }OCR 推理输出是一个[1, seq_len, vocab_size]的概率序列需要做 CTC 解码。解码规则是每个时间步取最大概率对应的字符索引合并相邻重复字符跳过 blank 索引。public static string DecodePlateOcr(float[] probs, int seqLen, int vocabSize, string[] vocab) { var sb new StringBuilder(); int lastIdx -1; for (int t 0; t seqLen; t) { int idx 0; float maxV -1f; for (int v 0; v vocabSize; v) { float val probs[t * vocabSize v]; if (val maxV) { maxV val; idx v; } } if (idx 0) { lastIdx -1; continue; } if (idx ! lastIdx) sb.Append(vocab[idx]); lastIdx idx; } return sb.ToString(); }逻辑说明idx 0是 CTC 的 blank 占位连续时间步重复字符只保留一个。车牌字符集一般包含汉字、大写英文字母和数字vocab从模型自带的字符表文件读取如果你的模型导出的字符表和标准 PP-OCR 字符表有差异需要以实际文件为准。解码后的字符串要做合法性过滤比如只保留字母和数字、剔除明显异常的长字符串这一步能挡住不少误识别结果。车牌识别对图像方向敏感倾斜角度过大的车牌最好先做透视矫正。PaddleDetection 里车牌模型本身对轻微倾斜有一定容忍度超过 15 度就建议用 OpenCV 的Cv2.FindContoursMinAreaRect转正后再送 OCR代价是每张车牌多花几毫秒。4.3 车型与颜色识别属性分类如何跟检测框联动车型和颜色识别共用同一个属性模型。输入是车辆检测框裁剪出来的整车图直接拉伸到 224×224分类输出通常是两个向量颜色类别和车型类别。解析代码public (string color, string type) ParseAttribute(float[] outputs, int colorClasses, string[] colorLabels, string[] typeLabels) { var colorProbs Softmax(outputs.Take(colorClasses).ToArray()); var typeProbs Softmax(outputs.Skip(colorClasses).Take(typeClasses).ToArray()); int colorIdx ArgMax(colorProbs); int typeIdx ArgMax(typeProbs); if (colorProbs[colorIdx] 0.5f) colorIdx -1; if (typeProbs[typeIdx] 0.5f) typeIdx -1; return ( colorIdx 0 ? colorLabels[colorIdx] : unknown, typeIdx 0 ? typeLabels[typeIdx] : unknown ); }逻辑说明属性输出先做 softmax 转概率再按阈值 0.5 过滤低置信度结果。颜色类别在暗光下置信度普遍偏低阈值可以放宽到 0.4车型类别如果分类优先级是卡车、客车这类大目标阈值保持 0.5 问题不大。颜色标签集通常有九类黑、白、红、黄、蓝、绿、灰、橙、棕车型标签集会根据训练数据不同而变化有的版本只分轿车、SUV、卡车、客车四类有的更细。模型标签列表可以在 Netron 里看输出层参数也可以在 PaddleDetection 源码包里找到vehicle_attribute的配置文件确认。属性模型和车牌模型串联的顺序建议是车辆检测 → 属性识别 → 车牌检测 → 车牌识别。属性识别只需要整车框车牌识别需要更精细的区域顺序排错会引入无谓的计算。5. 部署避坑五个反复出现的翻车现场与排查记录这一章有价值的不是“能跑通”的流程而是“跑不通时往哪个方向排查”。以下五类问题是我在 C# 部署 PP-Vehicle 时反复遇到的每条按现象、原因、解决顺序记录。5.1 设了 0.5 阈值却检不到车置信度相乘和坐标还原的双重问题现象把scoreThresh设成 0.5推理完一个框都不返回调到 0.05 又冒出一大堆重叠框。原因输出行的第 4 列和第 5 列分别是目标置信度和类别置信度两者相乘才是最终分数只看其中一列会漏另一个隐藏问题是以data[i * stride]硬编码 stride 假设成了[cx,cy,w,h,obj,cls]但部分版本的 PP-YOLO 导出输出多了一个class_id列stride 实际是 7按 6 解析时坐标错位严重。解决先在 Python 侧打印一次输出张量的 shape确定 stride 是 6 还是 7然后把阈值降到 0.15 观察输出逐步调回 0.25~0.3。坐标错位的判断方式是打印解析后的框如果框的 y 值明显超出图像高度多半是 stride 不对。5.2 C# 和 Python 结果不一致预处理里的通道顺序坑现象同一张图Python 脚本跑出车框置信度 0.82C# 端同一模型只有 0.61而且颜色分类结果不一样。原因PaddleDetection 训练时图像输入约定为 RGB预处理的最后一步做了通道交换C# 端用 OpenCV 读图后直接把 BGR 数据填入 tensor通道顺序反了模型特征自然对不上。颜色分类对通道顺序尤其敏感因为 RGB 和 BGR 交换后颜色空间完全错位。解决在LetterboxToTensor里交换通道OpenCV 读入 BGR 后按 R、G、B 顺序填入 tensor如果是灰度图转三通道不存在该问题。验证方法很简单把同一张图分别用 Python 和 C# 跑比较输出张量前几个数值偏差超过 0.01 就要查预处理。这一步是最玄学的坑也是新手最先怀疑“模型坏了”的原因。5.3 GPU 反而比 CPU 慢小模型单图推理的典型病现象给 OnnxRuntime 配置了 CUDA EP车辆检测模型快了一些但属性模型和车牌识别模型 GPU 推理反而比 CPU 慢 30% 以上。原因属性模型输入只有 224×224单张图推理时 GPU 的 kernel 启动开销、CPU 到 GPU 的拷贝开销占比太大计算量本身却很小车牌识别也是同理。模型越小GPU 的优势越难体现。解决车辆检测和车牌检测这类输入大、计算量大的模型用 CUDA EP属性识别和车牌识别保留 CPU EP。在 SessionOptions 里按模型分别指定 EPvar opts new SessionOptions(); if (useGpu) opts.AppendExecutionProvider_CUDA(0); else opts.AppendExecutionProvider_CPU(0); opts.IntraOpNumThreads Environment.ProcessorCount / 2; var session new InferenceSession(modelPath, opts);IntraOpNumThreads设置成物理核心数的一半避免和 OpenCV 的图像处理线程争抢 CPU。如果部署机器没有 NVIDIA GPUAppendExecutionProvider_CUDA会抛异常需要先做一次 CUDA 可用性检测或者干脆对所有模型用 CPU。5.4 车牌漏检率高ROI 切太准也会切坏结果现象正对车尾的车牌都能检出但侧停车辆、低照度场景车牌检测框频繁丢失。原因两个因素叠加。一是 ROI 裁剪按固定 55% 高度切车辆检测框如果上下位置有偏差车牌边缘正好落在裁剪边界上被切掉二是低照度下车牌与背景对比度低DB 文本检测的阈值在 0.3 时容易把弱纹理区域滤掉。解决ROI 裁剪时把expandRatio提到 0.3让车牌区域在裁剪图里只占中间位置低照度对 ROI 做 CLAHE 增强后再送检测。DB 检测阈值的调整要以实际漏检样本为准不要为了降误检把阈值抬到 0.6 以上那个数值下暗光车牌基本全丢。5.5 模型加载报文件不存在相对路径与工作目录的教训现象开发环境正常部署到另一台机器后启动即抛System.IO.FileNotFoundException提示找不到xxx.onnx。原因new InferenceSession(models/ppyeoloe.onnx)这类相对路径基于进程的当前工作目录用 Windows 服务、计划任务、开机自启动方式部署时工作目录通常不是 exe 所在目录相对路径解析失败。解决CS 项目里对 model 文件设置CopyToOutputDirectoryPreserveNewest加载时用Path.Combine(AppContext.BaseDirectory, models, xxx.onnx)显式拼绝对路径。AppContext.BaseDirectory在 .NET Core 和 .NET 5 里都是可靠的程序集所在目录不会因部署方式变化。部署前写一个自检方法启动时逐个检查四个 ONNX 文件是否存在缺失时给出清晰错误提示比运行时抛异常省事得多。6. 上线前的最后一步量化、并发与模型配置的小技巧前面几章解决了能不能跑、怎么跑对的问题最后一章处理性能、并发和模型管理。这里不做大而全的优化只讲能直接复用的几个技巧。6.1 Int8 量化的边界检测可以属性识别容易翻车OnnxRuntime 对 ONNX 的 Int8 量化支持已经比较成熟。我一般只对车辆检测模型做静态 Int8 量化因为检测框的鲁棒性相对高属性模型里的颜色识别对量化极其敏感暗光环境下 FP32 能识别的深色车Int8 量化后大概率输出置信度不足或直接分类错误。车牌识别模型也建议保守处理OCR 字符序列对概率分布细节要求高。量化前准备两百张代表场景的图做校准数据量化后用一份单独留出的验证集跑精度对比颜色分类准确率掉超过 2% 就回退到 FP32。6.2 多路并发Session 复用与线程数OnnxRuntime 的InferenceSession.Run在 CPU EP 下是线程安全的同一个 session 可以被多个线程并发调用只要每次传入的输入输出张量都是独立的。这意味着不需要为每路视频流创建一个 session可以共享同一个检测模型 session。真正需要小心的是线程数设置IntraOpNumThreads和InterOpNumThreads默认值在 CPU 上会吃满所有核心多路视频流并发时反而因为线程切换导致整体吞吐下降。我一般压测后把线程数限制在物理核心数附近并为每路流保留一个独立的输入缓冲避免张量复用造成的脏数据。6.3 模型路径抽到配置换模型不重编把四个模型路径抽成 JSON 配置文件程序启动时读取并加载。这样换一个车牌识别模型或者换一个更高精度的检测模型只改配置文件和模型文件不需要重新编译程序。字段可以这样设计模型名、路径、输入尺寸、置信度阈值、NMS 阈值、是否启用 GPU。这些参数全部进配置后后续调优时改配置文件比改代码快得多。我现在的工作习惯是每个模型目录里放一个model.json记录输入尺寸、标签列表、验证集精度部署时复制整个目录程序自动加载。这个习惯救过我很多次尤其是项目隔了几个月再回头调参没有这份记录所有参数又要重新试一遍。这套 PP-Vehicle 方案做到这个程度已经能从摄像头画面稳定输出结构化的车况数据了希望帮到你。本文还有配套的精品资源点击获取