找回密码
立即注册
搜索
发回帖 发新帖

6871

积分

0

好友

834

主题
发表于 3 小时前 | 查看: 3| 回复: 0

用 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 或性能提升幅度。

先把推理链路看完整

PaddleSharp PicoDet 轻量级目标检测 WPF 推理界面:检测多只猫并显示置信度、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 项目的检查清单

  1. 使用与项目一致的 Windows x64 目标框架,并确认 NuGet 包及原生运行时版本兼容。
  2. 准备推理导出模型目录,核对 model.pdmodel、model.pdiparams 和 infer_cfg.yml。
  3. 用一张已知样例先验证类别、坐标、输入尺寸和 NMS 结果。
  4. 选择目标设备可用的 CPU / GPU 后端,按设备测线程数,不要只依据桌面开发机。
  5. 分别测预热、稳态推理和端到端延迟;持续运行时观察资源占用和异常。
  6. 发布时检查原生 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 / 绑定线程规则搭建检测界面,并在目标设备上做可重复的性能测试。




上一篇:Switch 2《塞尔达传说:时之笛》重制版发售 2 天就被搬上 PC,无需模拟器
下一篇:C# CheckedListBox 勾选状态管理:ItemCheck 时机、全选联动与配置保存
您需要登录后才可以回帖 登录 | 立即注册

关闭

站长推荐上一条 /1 下一条

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-11-13 18:15 , Processed in 0.082833 second(s), 42 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表