用 C#、WPF、PaddleSharp 和 OpenCvSharp 在 Windows 边缘设备上跑 PicoDet-S,核心是把模型加载、后台推理、结果映射和性能测量几个环节拆开看,同时认清硬件后端与模型格式的边界。
如果你要在 Windows 工控机或边缘设备上做图片目标检测,推荐把模型推理封装在独立服务里:OpenCvSharp 读图,PaddleSharp 加载 PicoDet 导出的推理模型,再用 Task.Run 将推理移出 WPF UI 线程。关键在于匹配模型配置、推理后端和运行时,并且分别测量预热、推理与整条 UI 链路的耗时。下面的最小调用展示了核心路径。先记住两件事:模型不是训练权重,GPU 选项也不等于部署环境已经具备 CUDA。
using var image = Cv2.ImRead(imagePath, ImreadModes.Color);
if (image.Empty())
throw new InvalidOperationException("图片读取失败");
DetectionRunResult run = await Task.Run(() => engine.Run(image));
foreach (var item in run.Results)
Console.WriteLine($"{item.LabelName}: {item.Confidence:P1}");
项目材料基于 .NET 8、WPF、PaddleSharp 与 OpenCvSharp。由于文内没有给出具体硬件与实测报告,因此不声称特定 FPS 或性能提升幅度。
先把推理链路看完整

一个桌面端目标检测功能,不只是"模型跑一次"。实际链路至少包括:加载模型与原生运行时、读取并解码图片、按模型配置预处理、执行推理及后处理、把坐标映射成 UI 可用的数据,最后绘制或导出结果。不同环节的耗时和失败原因也各不相同。
本示例内置 PicoDet-S 320 COCO 模型,配置包含 320×320 输入、归一化与 Permute 预处理、标签表以及 NMS 阈值。应用通过 DetectionEngine 包装 PaddleDetector,由 ViewModel 管理界面状态;WPF 界面显示检测框、置信度,也支持批量图片浏览及 JSON / 标注图导出。
这里的"轻量级"指选择 PicoDet-S 作为边缘推理模型,并不代表所有设备都能达到某个固定帧率。模型速度最终取决于 CPU / GPU、推理后端、线程数、图像读写、模型配置及运行时版本。
为什么模型配置和运行时比几行调用更重要
推理模型不等于训练权重
PaddleDetection 训练产物常见 .pdparams,它不能直接作为这里 PaddleDetector 的模型目录。示例读取的是 Paddle 推理导出物:model.pdmodel、model.pdiparams 和 infer_cfg.yml。目录缺少其中必要文件时,应该在加载阶段就明确报错,而不是等到推理阶段才得到难以定位的失败。
配置文件也是模型契约的一部分。输入尺寸、颜色与归一化参数、类别标签、网络结构、NMS 等后处理设置必须与导出的模型一致。目录中的标签表是 COCO 80 类的中文名称;替换成自定义模型时,只改标签文字却不更新类别顺序,会导致"框看起来正常、类别却错了"。
模型运行时与硬件后端必须配套
工程引用了 PaddleSharp 检测与推理包,以及 Windows x64 的 MKL 推理运行时;模型执行后端可通过 PaddleDevice 选择 oneDNN、OpenBLAS 或 ONNX Runtime。CPU 环境可以先用 oneDNN 建立基线,再在目标设备上比较不同后端和线程数。不能仅凭机器装有 GPU 就切到 CUDA:GPU 对应的原生运行时、驱动、CUDA / cuDNN 版本和部署架构都必须匹配。
这个项目界面列出了 GPU 选项,但当前项目文件引用的是 MKL runtime。真正部署 GPU 之前,需要补齐并验证与所用 PaddleInference 版本匹配的 CUDA 运行时包;仅有一个枚举值并不能让 CUDA 推理真正跑起来。
C# 实现:将模型生命周期与 UI 解耦
1. 加载时校验推理模型目录
先集中处理路径解析和必要文件校验。这样可以在用户操作"加载模型"时给出明确错误,也避免界面逻辑里散落着模型路径和推理配置。
public void Load(string modelDir, InferenceDevice device, int threads = 0)
{
modelDir = Path.GetFullPath(modelDir);
if (!Directory.Exists(modelDir))
throw new DirectoryNotFoundException($"模型目录不存在:{modelDir}");
foreach (string file in new[] { "model.pdmodel", "model.pdiparams", "infer_cfg.yml" })
if (!File.Exists(Path.Combine(modelDir, file)))
throw new FileNotFoundException($"模型目录缺少 {file}", file);
var detector = new PaddleDetector(
modelDir,
Path.Combine(modelDir, "infer_cfg.yml"),
ResolveDevice(device, threads));
_detector?.Dispose();
_detector = detector;
ModelDir = modelDir;
Device = device;
}
这里先创建新的 PaddleDetector,创建成功后再释放旧实例并替换引用。如果新模型初始化失败,旧实例不会因为提前释放而丢失。生产代码还应该在 UI 上呈现原始异常信息,并记录模型目录、后端及版本;不要用空结果伪装加载成功。
2. 后端选择集中管理
把后端差异收敛在一处,调用方无需直接依赖每个后端的构造细节。当前示例的选择器如下:
public static Action<PaddleConfig> ResolveDevice(
InferenceDevice device, int threads = 0) => device switch
{
InferenceDevice.OneDnn =>
PaddleDevice.Mkldnn(cacheCapacity: 10, cpuMathThreadCount: threads),
InferenceDevice.Blas =>
PaddleDevice.Openblas(cpuMathThreadCount: threads),
InferenceDevice.Onnx =>
PaddleDevice.Onnx(cpuMathThreadCount: threads),
InferenceDevice.Gpu => PaddleDevice.Gpu(),
_ => PaddleDevice.Mkldnn(),
};
threads = 0 表示交给推理库采用默认策略;它不保证在所有设备上都是最佳值。多线程推理还会与图像解码、UI 线程和设备上其他任务争抢 CPU。上线前应以目标机器和真实图像做对照测试,而不是把开发机上的选项直接当作推荐配置。
3. 推理耗时只围住模型调用
计时范围要和指标名称一致。示例在 Run 中计量 detector.Run(src),随后按置信度排序并返回结果:
public DetectionRunResult Run(Mat src)
{
PaddleDetector detector =
_detector ?? throw new InvalidOperationException("模型尚未加载");
var sw = Stopwatch.StartNew();
DetectionResult[] results = detector.Run(src);
sw.Stop();
Array.Sort(results, (a, b) => b.Confidence.CompareTo(a.Confidence));
return new DetectionRunResult(results, sw.Elapsed.TotalMilliseconds);
}
该计时包括 PaddleSharp 执行过程中涉及的预处理、推理和后处理(具体内部边界依赖库实现),但不包括图片文件读取、WPF 绘制和结果导出。因此,界面显示的毫秒数不能直接代表"打开文件到画完检测框"的端到端延迟。
输入 Mat 应由调用方持有并释放;示例的调用处使用 using var mat,推理服务本身不接管图像资源所有权。对单个 PaddleDetector 实例,除非所用库明确保证线程安全,应用应串行化访问,避免多个并发任务同时调用同一实例。
4. WPF 中把耗时工作移出 UI 线程
模型加载、预热、图片解码和推理都可能耗时。WPF 绑定属性与集合更新应留在 UI 线程;耗时计算则放进后台任务,回到 await 后更新界面:
IsBusy = true;
StatusText = "推理中…";
try
{
DetectionRunResult run = await Task.Run(() =>
{
using var mat = Cv2.ImRead(path, ImreadModes.Color);
if (mat.Empty())
throw new InvalidOperationException($"无法读取图片:{path}");
return _engine.Run(mat);
});
_allResults.Clear();
_allResults.AddRange(run.Results.Select(
r => DetectionItem.FromResult(r, ImageHelper.GetBrush)));
RefreshVisible();
ElapsedText = $"{run.ElapsedMs:F2} ms";
}
catch (Exception ex)
{
StatusText = "推理失败:" + ex.Message;
}
finally
{
IsBusy = false;
}
await 默认回到 WPF 同步上下文,因此集合更新仍在 UI 线程执行。异常被转换为界面状态,方便用户区分模型失败与图像失败。正式产品可将异常同时写入日志,并增加取消和关闭窗口时的任务协调;取消令牌只有在所用推理 API 支持中断时,才能真正停止正在执行的原生推理。
5. 预热和基准测试分开看
首次执行可能包含模型初始化、后端缓存建立等额外成本。示例在模型加载后用白色 320×320 图像预热,再对实际输入单独计时。这可以降低冷启动对稳态观测的干扰,但并不意味着第一次真实图片会和预热图像表现完全相同。
工程提供 50 次基准测试:先额外运行一次,再记录 50 次推理时间,输出平均值、中位数、P95、最快和最慢值。这比只看单次结果更有参考价值。不过样本数量、图像内容、CPU 频率、温度、电源模式、后台负载和所选后端都会影响结果;对外发布性能表格时必须补充这些测试条件。
建议分别记录以下指标:
| 指标 |
包含内容 |
用途 |
| 模型加载与预热 |
构建推理器、首次调用 |
评估应用启动和换模体验 |
| 稳态推理 P50/P95 |
重复执行同一输入 |
比较后端、线程配置 |
| 端到端延迟 |
读图、推理、映射和绘制 |
判断操作体验 |
| 持续运行资源 |
CPU、内存、温度与错误率 |
验证边缘设备稳定性 |
示例基准测试中的平均 FPS 是由平均推理毫秒数换算,并不等同于摄像头实时帧率。若摄像头采集、缓冲、显示和推理并行,还要评估排队延迟和丢帧策略。
检测引擎代码
using System.Diagnostics;
using System.IO;
using OpenCvSharp;
using Sdcb.PaddleDetection;
using Sdcb.PaddleInference;
namespace PicoDetEdgeDemo.Services;
public enum InferenceDevice
{
OneDnn,
Blas,
Onnx,
Gpu,
}
public sealed record DetectionRunResult(
DetectionResult[] Results, double ElapsedMs);
public sealed class DetectionEngine : IDisposable
{
private PaddleDetector? _detector;
public bool IsLoaded => _detector is not null;
public string ModelDir { get; private set; } = string.Empty;
public InferenceDevice Device { get; private set; } = InferenceDevice.OneDnn;
public string[] Labels => _detector?.Config.LabelList ?? Array.Empty<string>();
public DetectionModelConfig? Config => _detector?.Config;
public void Load(string modelDir, InferenceDevice device, int threads = 0)
{
modelDir = Path.GetFullPath(modelDir);
if (!Directory.Exists(modelDir))
throw new DirectoryNotFoundException($"模型目录不存在:{modelDir}");
foreach (string file in new[] { "model.pdmodel", "model.pdiparams", "infer_cfg.yml" })
{
if (!File.Exists(Path.Combine(modelDir, file)))
throw new FileNotFoundException(
$"模型目录缺少 {file},请确认导出的是 Paddle 推理模型", file);
}
var detector = new PaddleDetector(
modelDir,
Path.Combine(modelDir, "infer_cfg.yml"),
ResolveDevice(device, threads));
_detector?.Dispose();
_detector = detector;
ModelDir = modelDir;
Device = device;
}
public DetectionRunResult Run(Mat src)
{
PaddleDetector detector =
_detector ?? throw new InvalidOperationException("模型尚未加载");
var sw = Stopwatch.StartNew();
DetectionResult[] results = detector.Run(src);
sw.Stop();
Array.Sort(results, (a, b) => b.Confidence.CompareTo(a.Confidence));
return new DetectionRunResult(results, sw.Elapsed.TotalMilliseconds);
}
public DetectionRunResult Warmup(int size = 320)
{
using var blank = new Mat(size, size, MatType.CV_8UC3, Scalar.White);
return Run(blank);
}
public static Action<PaddleConfig> ResolveDevice(
InferenceDevice device, int threads = 0) => device switch
{
InferenceDevice.OneDnn =>
PaddleDevice.Mkldnn(cacheCapacity: 10, cpuMathThreadCount: threads),
InferenceDevice.Blas =>
PaddleDevice.Openblas(cpuMathThreadCount: threads),
InferenceDevice.Onnx =>
PaddleDevice.Onnx(cpuMathThreadCount: threads),
InferenceDevice.Gpu => PaddleDevice.Gpu(),
_ => PaddleDevice.Mkldnn(),
};
public static string Describe(InferenceDevice device) => device switch
{
InferenceDevice.OneDnn => "MKLDNN (oneDNN)",
InferenceDevice.Blas => "OpenBLAS",
InferenceDevice.Onnx => "ONNX Runtime",
InferenceDevice.Gpu => "GPU (CUDA)",
_ => device.ToString(),
};
public void Dispose() => _detector?.Dispose();
}
结果坐标与 UI 展示
模型结果需要转换成界面层使用的数据。项目将标签 ID、名称、置信度与矩形坐标映射为 DetectionItem,再由 WPF Canvas 绘框;显示图片采用等比例缩放,所以叠加坐标也要乘同一缩放比例并加上居中偏移。直接把原图像素坐标当作 Canvas 坐标,会在图片缩放或留白时出现偏移。
置信度阈值与模型配置中的阈值也要区分。模型 NMS 会过滤一部分候选框,应用层的 Confidence 则可进一步过滤已经返回的结果。降低 UI 阈值无法找回模型后处理阶段已经丢弃的目标。
适用边界与替代选择
这套实现适合 Windows x64 桌面端、单机图片检测、边缘设备推理和验证模型部署流程。它能让 .NET 应用复用 Paddle 推理模型,在 WPF 中完成图片读取、目标框展示和结果导出。
有几类场景不应直接照搬:
- 高帧率视频或多路摄像头:需要有界队列、背压、丢帧策略和采集 / 推理 / 显示流水线,不能为每帧无限启动
Task.Run。
- 严格实时或安全联锁:普通桌面线程调度与推理延迟不能替代实时控制器;检测结果不应直接作为安全控制的唯一依据。
- 跨平台部署:当前目标框架是
net8.0-windows、使用 WPF 与 Windows runtime;Linux 容器或 ARM 设备需要重选 UI、原生推理包和部署方案。
- 模型不是 Paddle 推理格式:若手头是 ONNX 模型,应采用与模型契约匹配的 ONNX Runtime 流程;不能把扩展名改掉就交给 PaddleSharp。
- GPU 部署:需要真实 CUDA runtime 与驱动依赖、目标机器验证和发布包测试。CPU-only 版本应隐藏或禁用未配置的 GPU 选项。
如果只是低频单张图片处理,当前的单实例服务加 Task.Run 已足够,不必一开始引入复杂的多消费者队列。等到吞吐量、并发路数或延迟目标明确,再根据测量结果引入 Channel<T>、独立推理工作线程或进程隔离。
接入现有 .NET 项目的检查清单
- 使用与项目一致的 Windows x64 目标框架,并确认 NuGet 包及原生运行时版本兼容。
- 准备推理导出模型目录,核对
model.pdmodel、model.pdiparams 和 infer_cfg.yml。
- 用一张已知样例先验证类别、坐标、输入尺寸和 NMS 结果。
- 选择目标设备可用的 CPU / GPU 后端,按设备测线程数,不要只依据桌面开发机。
- 分别测预热、稳态推理和端到端延迟;持续运行时观察资源占用和异常。
- 发布时检查原生 DLL、模型文件、配置文件与许可证是否随程序正确部署。
代码片段依据项目中的 PaddleSharp API 写作;实际接入请以所选 NuGet 版本的 API 与运行时兼容说明为准。项目当前是 Windows x64 示例,不应未经验证就宣称可直接发布到 Linux、ARM 或 CUDA 环境。
结尾
PicoDet 在 .NET 边缘应用中的落地重点,不是单独调用一次模型,而是把模型格式、推理后端、后台线程和 UI 坐标链路一起管理好。先确保推理模型与配置匹配,再将耗时工作移出界面线程,最后用目标设备上的 P50 / P95 和端到端延迟验证选择。若你正在做工业视觉项目,当前更难解决的是模型部署依赖,还是多路图像的推理调度?
三条实践结论:
- 模型配置文件是推理契约,类别顺序与预处理参数不可随意更改。
Task.Run 能避免阻塞 UI,但不自动提供并发安全或真正的推理取消。
- "推理耗时"与"端到端耗时"不是同一个指标,测试报告要明确边界。
可复用的推理调用骨架:
using var mat = Cv2.ImRead(path, ImreadModes.Color);
if (mat.Empty())
throw new InvalidOperationException($"无法读取:{path}");
DetectionRunResult result = await Task.Run(() => engine.Run(mat));
学习路径
先熟悉 Paddle 推理模型的导出与 infer_cfg.yml,再了解 PaddleSharp 的设备配置和 OpenCvSharp 的 Mat 生命周期,最后结合 WPF Dispatcher / 绑定线程规则搭建检测界面,并在目标设备上做可重复的性能测试。