ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

P2PNet人群计数模型C#落地:ONNX推理与工程避坑全流程

P2PNet人群计数模型C#落地:ONNX推理与工程避坑全流程 简介这份资源是一套基于C#与ONNX Runtime的P2PNet人群检测与计数项目源码面向希望将深度学习模型集成到Windows桌面应用中的C#开发者。项目实现了从图像输入、模型推理到结果可视化的完整流程可应用于安全监控、公共活动管理等场景。压缩包共77个文件约84.29MB包含cs源码、sln解决方案、onnx模型、dll依赖库以及jpg测试图像等其中Visual Studio解决方案与项目文件可直接打开编译模型文件为SHTechA.onnx配套资源完整。已有670人学习下载。作者在博客中提供了实现细节代码结构清晰涵盖模型加载、推理、后处理与界面展示等关键环节适合用于理解P2PNet在C#环境下的落地方式也可作为计算机视觉与ONNX Runtime结合的实践参考。1. 把 P2PNet 人群计数搬进 C# 工程省掉 Python 服务的一条落地路径做商超、景区或展馆客流统计时最常见的分工是Python 侧训好模型、开一个 HTTP 服务C# 上位机再去调接口。跑是能跑但现场总有意外——Python 环境被搞坏、缺包、内存涨了没人管。后来我换了一个思路直接把 P2PNet 转成 Onnx用 C# 在进程内加载并推理人数统计、点位绘制、报表导出全在一个 exe 里做完。P2PNet 是一个点监督人群计数模型它输出的不是检测框而是每个人的位置点加一个全局人数在密集人群里比带框检测器更抗遮挡。这条路适合 C# 上位机工程师和想自研客流统计模块的团队不需要维护 Python 环境不依赖云服务模型文件随程序分发现场部署难度低。下面从模型原理、Onnx 导出、C# 推理、后处理计数到真实踩坑完整讲一遍。2. P2PNet 点回归原理它输出的不是框而是每个人的位置第一次接触 P2PNet 时最需要扭转的是对“检测”的理解。常见 YOLO 输出的是框中心点、宽高、类别概率P2PNet 完全丢掉框改用点回归。它先由 backbone 提取特征再在特征图上回归人头位置最终在图像上形成一个个响应峰峰顶上就是头。这种做法的优势很直接人群一密集框之间互相重叠、NMS 把真头消掉是常事而点与点之间只需要简单抑制就能分开。2.1 P2PNet 的三个输出概率响应图、偏移图与全局计数P2PNet 的点回归头真正产出的信息可以拆成三份一份概率响应图形状是[1, H, W]每个像素值表示“这里是人的置信度”一份偏移图形状是[1, 2, H, W]两个通道分别对应 dx、dy用来把响应峰的位置修正到更精确的人头中心最后还有一个全局计数标量[1, 1]。导出 Onnx 时我建议把前两者合并成一个三通道输出即通道 0 放概率响应通道 1、2 放 dx/dy这样 C# 端的解析逻辑最直接。三者的关系是概率图负责“哪里有人”偏移图负责“点得更准”全局计数负责“兜底的数量感”。但全局计数不能直接信——它是训练时由网络整体回归出来的标量和响应峰的局部一致性并不总匹配实际部署时我只拿它做校验不拿它当输出。2.2 点监督与 Anchor 检测器在密集人群上的差异用过 YOLO 数人头的人应该都有体验站在展台前的人群大量头与头互相压住YOLO 靠 NMS 抑制重复框两个人挨得稍近就直接并成一个框。P2PNet 这类点监督模型在训练时学到的是响应峰的分布两个人头几乎相切时概率图上仍然能看出两个峰。代价是它对输入分辨率和相机角度更敏感这决定了部署时不能无脑放大输入尺寸。我用 1024x768 作为固定输入就是在这个权衡上折中的结果。2.3 用 OnnxRuntime 进程内推理省掉 Python 服务一个探针先验证C# 侧能直接选的原生推理方式就是 OnnxRuntime。注意 onnx 和 onnxruntime 是两回事.onnx是模型文件格式onnxruntime 是加载并执行这个文件的推理引擎。工程上要分清你拿到的是.onnx文件C# 代码里引用的是Microsoft.ML.OnnxRuntimeNuGet 包。进程内推理替代 Python 服务收益有三条部署简单一个 exe 加一个模型文件现场不用装 Python延迟可控省掉 HTTP 和进程间拷贝故障面小没有独立服务要守护。拿到任何.onnx模型我建议先跑一段探针代码把输入输出名和维度打出来省得后面对着报错猜名字using Microsoft.ML.OnnxRuntime; var options new SessionOptions(); options.AppendExecutionProvider_CPU(); using var session new InferenceSession(p2pnet.onnx, options); foreach (var meta in session.InputMetadata) Console.WriteLine($输入{meta.Key} {string.Join(,, meta.Value.Dimensions)}); foreach (var meta in session.OutputMetadata) Console.WriteLine($输出{meta.Key} {string.Join(,, meta.Value.Dimensions)});这段探针是后续所有工作的地基。输入输出名字以打印结果为准不要想当然用 input、output维度里出现 -1 表示动态维度推理时要传入具体 batch、高和宽。如果探针阶段发现输出数量比预期多说明导出时没有裁剪干净先解决再往下走。3. 把 PyTorch P2PNet 导出为 Onnx导出脚本与动态轴设置C# 端拿到的模型不会凭空出现PyTorch 训练产物是.pth权重必须先转成.onnx。转换脚本有两个要点一是把推理用不到的 forward 分支裁剪掉二是把动态轴的选择权交给自己而不是用 torch 默认的静态导出。下面这份脚本配合 P2PNet 的预训练权重可以直接用。3.1 导出前先裁剪 forward 输出用包装类固定输出结构P2PNet 的 forward 在训练态和推理态有差异训练时要算损失返回的是多组中间量导出 Onnx 时必须只保留推理路径。常见做法是写一个包装类显式返回合并后的输出和全局计数import torch import torch.nn as nn class OnnxExportModel(nn.Module): def __init__(self, model): super().__init__() self.model model def forward(self, x): # 按你手里的 P2PNet 权重约定调整通道索引 points, count self.model(x) # points: [B, 2, H, W], count: [B, 1] heatmap points[:, 0:1, :, :] # 通道0人头置信度响应 offset points[:, 1:3, :, :] # 通道1、2dx, dy out torch.cat([heatmap, offset], dim1) # 合并成 [B, 3, H, W] return out, count这个包装类解决的是黑匣子问题不包装直接导出计算图里可能会残留训练分支输出个数、顺序都不确定。合并三通道是为了让 C# 端只处理一个输出张量少一道张量拆分的麻烦。3.2 完整导出脚本torch.onnx.export 的 opset 与 dynamic_axes 参数下面是一份完整的导出脚本固定推理尺寸为 768x1024只给 batch 留动态def export_onnx(ckpt_path, out_pathp2pnet.onnx): from model import P2PNet # 按你的工程路径导入 model P2PNet() state torch.load(ckpt_path, map_locationcpu) model.load_state_dict(state[model] if model in state else state) model.eval() H, W 768, 1024 dummy torch.randn(1, 3, H, W) wrapped OnnxExportModel(model) torch.onnx.export( wrapped, dummy, out_path, opset_version13, do_constant_foldingTrue, input_names[input], output_names[output, count], dynamic_axes{ input: {0: batch}, output: {0: batch}, count: {0: batch} } ) print(f导出完成{out_path})几个参数值得说明。opset_version13在 OnnxRuntime 里兼容面最大老设备也不用担心算子缺失do_constant_foldingTrue把能折叠的常量计算在导出阶段算完减少运行时开销。dynamic_axes这里只放开 batch不放开高宽是故意的C# 端统一把图像 resize 到 768x1024模型输入尺寸固定后内存分配和边缘行为都可预期。如果你坚持要动态高宽在dynamic_axes里补2: height, 3: width但代价是 C# 端每个尺寸都要重新分配输出缓冲区部分卷积算子在非标准尺寸下的行为也需要重新验证。3.3 落盘后先跑探针onnx 与 onnxruntime 的分工导出完成后用一段 Python 探针先确认模型真的能跑再交给 C#import numpy as np import onnxruntime as ort sess ort.InferenceSession(p2pnet.onnx, providers[CPUExecutionProvider]) inp np.random.rand(1, 3, 768, 1024).astype(np.float32) output, count sess.run([output, count], {input: inp}) print(output.shape, count.shape) # 期望 (1, 3, 768, 1024) (1, 1)这里用到的依赖是onnxruntime不是onnx库。onnx库负责检查模型结构和算子定义onnxruntime负责真正把模型跑起来这两者经常被混着说但工程安装包完全不同。探针跑通后就能进 C# 阶段了。4. C# OnnxRuntime 跑通 P2PNet推理类、后处理与计数逻辑进了 C# 阶段实际要解决三件事把 Bitmap 变成模型要的 Tensor把模型输出解成坐标点再把坐标点映射回原图并完成计数。我倾向把这套逻辑收敛成一个CrowdCounter类对外只暴露Count(Bitmap)上位机主程序不关心模型细节后续换模型也不动主界面代码。下面这份类代码就是这个方向可直接抄的源码骨架。4.1 CrowdCounter 类骨架模型路径、输入尺寸与阈值统一收口先通过 NuGet 安装Microsoft.ML.OnnxRuntime。如果程序要发布到老现场机器注意把 Runtime 包换成对应平台或者用自包含发布把原生 dll 一起带上。类里建议保存六个字段session、输入输出名、目标宽高、置信度阈值、NMS 窗口半径using Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime.Tensors; using System.Drawing; public class CrowdCounter : IDisposable { private readonly InferenceSession _session; private readonly string _inputName; private readonly string _outputName; private readonly string _countName; private readonly int _targetW; private readonly int _targetH; private readonly float _threshold; public CrowdCounter(string modelPath, string inputName input, string outputName output, string countName count, int targetW 1024, int targetH 768, float threshold 0.2f) { var options new SessionOptions(); options.AppendExecutionProvider_CPU(); _session new InferenceSession(modelPath, options); _inputName inputName; _outputName outputName; _countName countName; _targetW targetW; _targetH targetH; _threshold threshold; } public void Dispose() _session.Dispose(); }构造函数的参数都是从探针阶段打出来的实际名字不要硬编码。阈值_threshold放在这里而不是写死在后处理里是因为不同场景的响应峰强弱差异很大现场调参时你不想改一行代码重新编译。4.2 Bitmap 到 Tensor 的预处理与推理调用预处理的关键是等比缩放加灰边填充而不是直接拉伸。P2PNet 对宽高比变形很敏感原图 1920x1080 直接压成 1024x768人头会横向变胖响应峰被拉糊等比缩放后剩余区域用灰色填充模型看到的比例基本不变。下面这段把 resize、归一化和推理串在一起public (ListPointF Points, int Count) Process(Bitmap bmp) { using var resized ResizeWithPadding(bmp, _targetW, _targetH); var tensor BitmapToTensor(resized, _targetW, _targetH); var inputs new ListNamedOnnxValue { NamedOnnxValue.CreateFromTensor(_inputName, tensor) }; using var results _session.Run(inputs); var output results.First(r r.Name _outputName).AsTensorfloat(); var countTensor results.First(r r.Name _countName).AsTensorfloat(); var points DecodePoints(output, bmp.Width, bmp.Height, _targetW, _targetH); int count points.Count; return (points, count); } private DenseTensorfloat BitmapToTensor(Bitmap bmp, int w, int h) { var tensor new DenseTensorfloat(new[] { 1, 3, h, w }); for (int y 0; y h; y) { for (int x 0; x w; x) { var c bmp.GetPixel(x, y); tensor[0, 0, y, x] c.R / 255f; tensor[0, 1, y, x] c.G / 255f; tensor[0, 2, y, x] c.B / 255f; } } return tensor; }Process返回的点列表和人数是整个上位机模块的对外口径。BitmapToTensor里用GetPixel是为了代码可读性生产环境建议用LockBits或 ImageSharp 批量拷贝像素否则 768x1024 逐像素调用GetPixel每帧耗时明显。归一化这里只做了除以 255P2PNet 官方预处理通常就是 0~1 区间如果你手里的训练脚本用了 ImageNet 均值方差把对应数值替换进去即可。4.3 后处理解码局部最大抑制、坐标映射与 Count 一致性_session.Run之后output的布局是[1, 3, H, W]通道 0 是概率响应通道 1、2 是 dx、dy。解码的核心是局部最大抑制——一个头在概率图上可能会形成几个相邻的强响应点只用阈值过滤会数出好几个点把 3x3 邻域内不是最大的点删掉才能保证“一个头只算一次”private ListPointF DecodePoints(DenseTensorfloat output, int origW, int origH, int targetW, int targetH) { int h targetH, w targetW; var list new ListPointF(); float scaleX origW / (float)w; float scaleY origH / (float)h; for (int y 1; y h - 1; y) { for (int x 1; x w - 1; x) { float prob output[0, 0, y, x]; if (prob _threshold) continue; if (!IsLocalMax(output, x, y, w, h)) continue; float dx output[0, 1, y, x]; float dy output[0, 2, y, x]; list.Add(new PointF( (x dx) * scaleX, (y dy) * scaleY)); } } return list; } private static bool IsLocalMax(DenseTensorfloat t, int x, int y, int w, int h) { float v t[0, 0, y, x]; for (int dy -1; dy 1; dy) { for (int dx -1; dx 1; dx) { int yy y dy, xx x dx; if (yy 0 || yy h || xx 0 || xx w) continue; if (t[0, 0, yy, xx] v) return false; } } return true; }坐标映射的公式是(x dx) * scaleX这里的scaleX是原图宽除以目标宽。如果你加了灰边填充映射前还要先减去灰边偏移量否则点位会整体偏移。点数量就是list.Count我用ListPointF而不是数组收集因为点的数量在推理前不确定动态集合在解码场景下更顺手。后处理里有三个参数影响最大我在现场调参就是围绕这张表来的参数建议起始值作用调参方向置信度阈值0.2过滤低置信响应峰点太多就调高点太少就调低NMS 窗口3x3同一个头只保留一个峰密集场景可保持 3稀疏场景可放大到 5偏移图叠加dx、dy 原值修正峰位置到人头中心点位偏左上或右下时检查这里4.4 把计数结果推给 UI事件回调代替轮询上位机界面刷新不适合每帧去拉结果。我会把CrowdCounter包在一个后台采集线程里跑完推理后用 C# 的事件把(Points, Count)推给 UI 线程——这是 C# 委托最典型的应用场景。WinForms 或 WPF 订阅事件后更新客流折线和热力图跨线程操作控件时用SynchronizationContext或Dispatcher切回 UI 线程。这样数据采集、AI 推理、界面显示三者解耦后续把计数结果推给 TCP 客户端或数据库也只在事件处理里加代码不用动推理逻辑。5. P2PNet Onnx 部署避坑5 个让我返工过的现场问题5.1 现象导出后输出从 2 个变成 4 个points 找不到了第一次导出时我直接拿原始模型去转 Onnx结果探针打出来 4 个输出名字也不叫 points。原因很典型P2PNet 的 forward 里还有一些计算图分支没有被裁剪包括训练时用的辅助张量它们也被一并导出成了输出节点。解决方法是回到 3.1 的包装类做法把 forward 显式收敛成两个返回值导出后再用探针确认输出名严格等于你指定的名字。如果你已经有导出的模型但不想重新导出可以在 C# 里只取第一个输出但要确认维度是[1, 3, H, W]而不是别的这一步别省。5.2 现象阈值 0.1 人数多一倍0.3 人数少一半概率响应图的值并不是标准概率分布不同场景、不同人群密度下响应峰的绝对高度差别很大。固定阈值 0.2 在室内灯光均匀时效果不错到了傍晚或逆光场景响应峰整体变矮0.3 会把真头切掉一半调低到 0.1背景噪声又全进来了。解决思路是让阈值自适应先用全局计数做校准把阈值从 0.1 开始往上扫扫到点数与全局计数的差值最小这个阈值就是当前场景的临时最优值。现场环境稳定的话开机自检跑十帧定一次阈值就够了。5.3 现象点位整体偏右上和画面里的人对不上画出来的点整体偏移通常不是模型问题而是坐标映射的坐标原点不一致。我返工最久的一次是原图有顶部黑边预处理时裁掉了黑边再 resize但后端映射时忘了把黑边的偏移量加回来导致所有点都向右下偏。另一个常见原因是 dx、dy 的通道顺序反了偏移图的第一个通道存的是 dy第二个通道才是 dx叠加时写反点位就会整体往对角线方向移动。验证方法是拿一张单人的测试图把解码出的点直接画回原图上如果点落在人头上就说明映射正确。5.4 现象广角画面人群挤在下方固定尺寸后计数明显偏少固定输入 1024x768 后广角摄像头拍摄的 1920x1080 画面如果直接拉伸下半部分人群会被压扁响应峰糊成一片计数自然少。这个问题的根源不是模型而是预处理破坏了宽高比。解决方法是坚持等比缩放加灰边填充不要在调用端用Graphics.DrawImage简单拉扯。如果现场画面区域相对固定更推荐先裁出 ROI 再进模型既减少形变又降低背景噪声。我踩过这个坑之后把所有缩放逻辑都收进了CrowdCounter不给调用方留自由发挥的空间。5.5 现象int8 量化后计数从 152 掉到 37为了省内存和加快推理我用 OnnxRuntime 的 int8 量化把模型压了一轮结果计数直接崩掉。原因是 P2PNet 的偏移图对数值精度极为敏感dx、dy 的数值通常很小uint8 量化后小数部分被吃掉峰位置偏移得离谱概率响应图虽然损失不大但整体阈值响应已经变了。解决思路如果要量化优先用 per-channel 量化而不是 per-tensor校准集必须从现场截取一两百帧代表数据不能用网图拼如果现场机器对体积和速度没有硬性要求float32 在 CPU 上跑到 30ms 一帧已经够用不建议第一版就上 int8。6. 验证计数可靠性的三个实操技巧基准帧、计数一致性与多客户端联动模型跑通只是第一步真正要解决的是“怎么知道数得准”。我每接一个现场都会先做两件事存一组基准帧跑离线结果再对着count输出和点列表做一致性检查。6.1 基准帧闭环离线跑 20 帧算 MAE从现场视频里抽 20 帧覆盖不同时段人工数出真实人数再用离线程序跑模型算平均绝对误差。这个基线要存档以后任何改动——换模型、调阈值、改预处理——都重新跑一遍对照验证项通过标准失败时先查人工数 vs 模型数 MAE50 人场景误差 ≤3 人阈值整体偏高或偏低count 输出 vs 点数两者差 2解码通道布局解析错误单帧 CPU 耗时1024x768 ≤50ms输入尺寸偏大或 Session 反复创建我习惯把count输出和points.Count同时打出来观察。如果两者差得大说明解码逻辑和模型约定不一致先检查通道顺序再检查阈值。6.2 把计数结果推到多客户端TcpListener 的轻量广播客流统计的消费端往往不止一个大屏显示一个数字后台系统要分钟级报表值班室要看实时曲线。常见做法是用TcpListener接收多个客户端连接把(timestamp, count, points)序列化成 JSON 广播出去。C# 的TcpListener天然支持多客户端管理新客户端连接后单独开一个发送队列计数事件触发时逐个写入。注意点位数据量大广播时通常只发人数和热力图缩略坐标不要每帧把几百个点全量推给所有客户端。我最后养成的两个习惯一是所有现场机器都保留一份基准帧的离线测试程序排障时先跑离线再看实时二是任何改模型的动作第一件事不是看计数而是看点位画出来准不准。点位准数量自然准。希望帮到你。本文还有配套的精品资源点击获取
返回列表