C# 应用要跑 PP-YOLO,真正麻烦的地方往往不是调用一次推理接口,而是模型导出格式、推理配置、原生运行时、采集线程和 WPF 刷新节奏这几件事能不能对齐。本文基于一个 .NET 8 WPF 工程,结合 OpenCvSharp 和 Sdcb.PaddleDetection,把这条链路拆开说明,重点放在实际接入时容易出问题的边界上。
一、链路划分:四段各司其职
C# 端不负责重写 PP-YOLO,也不该把训练 checkpoint 直接交给推理 API。常规做法是先在 PaddleDetection 侧完成训练或选型,导出 Paddle Inference 能加载的模型目录,C# 端再加载该目录和 infer_cfg.yml,由推理封装完成预处理、前向计算和结果解码。
整个程序按职责分四段:OpenCvSharp 负责从摄像头、视频或图片读入 Mat;PaddleDetection 封装根据配置做预处理并调用 Paddle Inference;应用层按置信度、类别、数量过滤结果并把框裁剪到图像范围内;最后在帧上绘框,通过 WPF Dispatcher 更新 WriteableBitmap 和统计面板。
这样拆的意义在于模型 API 只停留在推理服务内部。界面层面对的是统一接口,换模型、换演示引擎或者补别的推理后端时,不需要把模型细节散到各个按钮和控件里。入口调用大致是这样:
var detector = new PaddleDetector(modelDir, cfgPath, PaddleDevice.Mkldnn());
using var frame = Cv2.ImRead("sample.jpg");
var results = detector.Run(frame);
二、能加载不等于检测正确
检测模型的配置文件不只是类别名称,它还包含输入尺寸、缩放方式、归一化参数和输出阈值。示例配置里能看到 320、608、640 等不同输入分辨率,预处理涉及 Resize、NormalizeImage 和 Permute。如果模型按固定尺寸和指定归一化导出,应用侧再手工做一遍缩放或归一化,就等于重复预处理,结果会偏。
工程直接调用 PaddleDetector.Run(Mat),由封装读取配置走完整流程,调用前不再额外处理。使用自训模型时,导出模型、infer_cfg.yml、类别标签和应用期望必须保持一致。
另一个容易误解的点是“实时”。速度由模型、输入尺寸、CPU/GPU、采集和 UI 刷新共同决定,没有在目标设备上实测过,就不适合给出固定 FPS。当前工程在采集线程里逐帧同步推理,没有额外堆一个无界帧队列;推理变慢时表现为处理帧率下降或画面延迟,具体还和设备驱动的缓存行为有关。
界面预览

三、从模型目录到检测引擎
3.1 运行环境与模型准备
示例目标框架为 net8.0-windows,使用 WPF、OpenCvSharp 4.10、Sdcb.PaddleDetection 2.3.3.1 和 Sdcb.PaddleInference 2.5.0.1。CPU 路径选择 MKL/oneDNN 推理运行时。训练模型需要导出为 Paddle Inference 支持的格式,同时准备好模型目录和对应的 infer_cfg.yml。仓库里的 models 目录只是 PP-YOLO 系列配置示例,包含不同模型和输入尺寸,并不等于权重文件,部署时要从可信来源准备匹配的导出文件,目录结构以所用 PaddleDetection/Sdcb 版本要求为准。
CPU 和 GPU 的差别不只是一个开关。工程清单里包含 Windows x64 的 MKL CPU 运行时;GPU 还需要匹配的 CUDA 版 Paddle Inference 原生依赖以及 CUDA/cuDNN 环境。界面提供 GPU 选项,并不代表当前 NuGet 依赖已经带齐 GPU 所需的全部原生库。稳妥的做法是先用 CPU 跑通,再按 Sdcb 对应版本的部署说明安装 GPU 运行时,避免因为缺 DLL 或版本不兼容而误判成模型问题。
3.2 用接口隔离推理实现
应用把推理能力收敛在 IDetectionEngine,统一提供模型信息、标签、检测方法和资源释放。摄像头流程只关心“输入一帧,拿到检测对象”,不依赖 PaddleDetector 的具体类型。核心实现如下:
using System.IO;
using System.Text.RegularExpressions;
using OpenCvSharp;
using PaddleDetectionPPYolo.Models;
using Sdcb.PaddleDetection;
using Sdcb.PaddleInference;
namespace PaddleDetectionPPYolo.Services;
public sealed class PaddleDetectionEngine : IDetectionEngine
{
private readonly PaddleDetector _detector;
public PaddleDetectionEngine(string modelDir, string cfgPath, InferenceDevice device, int deviceId = 0)
{
Action<PaddleConfig> configure = device == InferenceDevice.Gpu
? PaddleDevice.Gpu(deviceId: deviceId)
: PaddleDevice.Mkldnn();
_detector = new PaddleDetector(modelDir, NormalizeConfig(cfgPath), configure);
Labels = _detector.Config.LabelList ?? Array.Empty<string>();
var name = Path.GetFileName(modelDir.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
Name = string.IsNullOrWhiteSpace(name) ? "PP-YOLO" : name;
Description = $"{_detector.Config.Arch} · {Labels.Count} 类 · {device}";
}
private static string NormalizeConfig(string cfgPath)
{
string text = File.ReadAllText(cfgPath);
if (Regex.IsMatch(text, @"(?im)^\s*use_dynamic_shape\s*:"))
{
return cfgPath;
}
string patched = Path.Combine(Path.GetTempPath(), $"infer_cfg_{Guid.NewGuid():N}.yml");
File.WriteAllText(patched, text + Environment.NewLine + "use_dynamic_shape: false" + Environment.NewLine);
return patched;
}
public string Name { get; }
public string Description { get; }
public IReadOnlyList<string> Labels { get; }
public DetectedObject[] Detect(Mat frame)
{
var raw = _detector.Run(frame);
var result = new List<DetectedObject>(raw.Length);
foreach (var item in raw)
{
if (item.IsRBox)
{
continue;
}
var rect = item.Rect;
int x1 = Math.Clamp(rect.Left, 0, frame.Width - 1);
int y1 = Math.Clamp(rect.Top, 0, frame.Height - 1);
int x2 = Math.Clamp(rect.Right, x1 + 1, frame.Width);
int y2 = Math.Clamp(rect.Bottom, y1 + 1, frame.Height);
string label = string.IsNullOrWhiteSpace(item.LabelName) ? $"类别 {item.LabelId}" : item.LabelName;
result.Add(new DetectedObject(item.LabelId, label, item.Confidence, x1, y1, x2, y2));
}
return result.ToArray();
}
public void Dispose() => _detector.Dispose();
}
这段代码里有几处设计值得保留。配置和模型成对传入,避免把某个模型的预处理写死成全局逻辑;检测框先裁剪到图像范围,再保证矩形宽高至少为 1,防止绘图阶段拿到无效区域;当前 UI 绘制器只画普通矩形,所以有意跳过 IsRBox 结果,如果模型输出旋转框,需要单独实现多边形或旋转矩形绘制,不能当成普通框处理;推理引擎包装了本机资源,替换模型或关闭应用时要调用 Dispose()。
仓库实现还会检查 infer_cfg 是否包含 use_dynamic_shape,缺少时生成临时配置副本并补上 false。这是针对当前封装和配置兼容的处理,升级 PaddleDetection 或改变模型导出方式后应重新核对,不宜在不了解部署版本的情况下直接照搬。
3.3 帧回调进入推理,再回到 WPF
采集服务在后台长时间运行任务中读取帧,并同步调用帧处理回调。关键顺序如下:
private void ProcessFrame(Mat frame, CancellationToken token)
{
if (token.IsCancellationRequested) return;
var engine = _engine ?? _demoEngine;
var watch = Stopwatch.StartNew();
var objects = engine.Detect(frame);
watch.Stop();
var visible = Filter(objects);
FrameRenderer.Draw(frame, visible, ShowLabels, (int)BoxThickness);
_frameView.Present(frame);
var payload = new FramePayload(visible, watch.Elapsed.TotalMilliseconds);
_dispatcher.BeginInvoke(new Action(() => ApplyFrame(payload)));
}
推理、过滤和绘制都留在采集工作线程;更新 WPF 绑定属性和写入 WriteableBitmap 回到 Dispatcher。工程里的 WriteableBitmapFrame.Present 先把像素复制到受锁保护的缓冲区,Flush 再在 UI 线程写入位图,避免后台线程直接操作 WPF 控件。
CancellationToken 在这里只用于停止前检查。同步的 _detector.Run(frame) 没有接收 token,所以取消不能强行中断正在执行的单次推理,停止操作要等当前推理返回后才完全生效。资源释放同样要避免和正在执行的 Detect 并发。
3.4 UI 刷新要有节奏
采集、推理和界面展示不一定同速。工程对检测统计面板做了约 200 毫秒一次的刷新限制,避免每帧清空并重建列表,画面仍按处理帧提交。这是简单有效的 UI 降载方式,但 Dispatcher.BeginInvoke 仍会为每帧排入更新任务,UI 繁忙而推理又足够快时,待处理委托可能积压。
如果目标设备上观察到 UI 延迟增长,可以把“最新画面”改成可覆盖的单帧缓冲,用 WPF DispatcherTimer 按固定节奏消费,表格统计用更低频率刷新。这里要区分实时画面和必须逐条保留的业务事件:画面可以丢旧帧,报警记录和审计事件不能简单覆盖。
运行时错误也要沿状态通道呈现。帧推理失败时记录可读错误并停止采集;模型加载失败时不要继续以“模型已就绪”的状态运行。生产项目应接入结构化日志,并为模型路径、原生库加载、设备选择和图片读取分别提供可定位的信息。
四、结果过滤与性能测量
工程提供置信度阈值、类别筛选和最大检测数限制,并测量单帧 Detect 的耗时。过滤逻辑类似:
IEnumerable<DetectedObject> query =
results.Where(x => x.Score >= ConfidenceThreshold);
if (SelectedClassFilter != AllClassesLabel)
query = query.Where(x => x.Label == SelectedClassFilter);
return query.OrderByDescending(x => x.Score)
.Take(MaxDetections)
.ToArray();
要注意示例 infer_cfg.yml 里还有 draw_threshold。推理封装可能已经按配置阈值过滤过结果,应用层再提高阈值可以继续减少展示结果,但降低应用阈值不一定能恢复在模型后处理阶段已被剔除的框。调整阈值时要看清完整输出链路。
仓库没有提供经过目标硬件验证的性能基准,所以不适合列出虚构的 FPS 或提升比例。自测时至少固定模型、输入分辨率、CPU/GPU、视频源和采集分辨率,预热后分别记录推理耗时分布、端到端帧率、UI 延迟和内存占用。摄像头实时推理和离线图片批处理不是同一类负载,不能只看单张推理耗时就判断实时效果。
五、适用范围与取舍
| 场景 |
这套方案的适配度 |
需要额外考虑 |
| Windows WPF 工具、设备调试界面 |
适合快速集成 |
本机推理运行时、摄像头兼容性 |
| 图片或视频文件检测 |
适合 |
处理时间、批量吞吐 |
| 工业相机与产线实时控制 |
可做显示/验证起点 |
相机 SDK、硬实时要求、丢帧策略、安全联锁 |
| Linux 服务或跨平台客户端 |
不适合直接照搬 |
当前工程是 net8.0-windows,依赖 Windows 原生包 |
| 分割、跟踪、旋转框任务 |
需扩展 |
结果模型、后处理和 UI 绘制都要支持对应任务 |
这套组合的优势是 C# 应用可以复用 PaddleDetection 生态中的模型,不必把整个桌面应用改写成 Python;代价是要同时维护 .NET 包、Paddle Inference 原生运行时、模型导出格式和部署机器上的依赖版本。
如果只是做离线图片识别,同步调用就够了,不必先搭复杂的生产者/消费者架构。如果是高帧率相机、多路视频,或者处理速度跟不上采集,才需要考虑有界队列、丢帧策略和独立推理工作线程。队列必须有容量上限,否则消费速度落后时,内存和画面延迟会持续增长。
工业控制场景还要把“检测结果”和“控制动作”分开。目标检测输出带置信度和时延,不应单独成为安全联锁或唯一的设备保护机制。需要确定性响应时,应有独立的 PLC/安全控制逻辑,并针对光照、遮挡、相机故障、模型异常和推理超时制定降级策略。
六、落地顺序建议
先用图片跑通模型与标签,再接入视频,最后接摄像头和 UI。这样遇到问题时能区分是模型、运行时、采集还是线程调度。C# 调 PaddleDetection 的关键不是只装 NuGet 包,而是加载匹配的推理模型、配置和原生运行时;推理与采集放后台,WPF 控件更新回 Dispatcher;高频 UI 刷新要测量并限制;不要拿未经验证的 FPS 做承诺,用目标机器、真实输入和端到端指标测出自己的边界。
学习路径可以按这个顺序走:PaddleDetection 模型导出,Paddle Inference 部署,Sdcb .NET 封装,OpenCvSharp 图像采集,WPF Dispatcher 与 WriteableBitmap。
总结
把 PP-YOLO 接进 WPF,核心工作不在按钮事件,而在模型导出、配置匹配、原生依赖和线程边界。推理服务用接口隔离,采集线程逐帧处理,UI 线程只做位图和绑定更新;结果过滤要理解配置阈值与应用阈值的关系,性能结论要来自目标设备实测。对桌面端边缘检测原型和轻量应用,这条链路可以较快验证;对硬实时工业控制,还需要额外的相机接入、丢帧策略和独立安全逻辑。