简介:这份资源是一套基于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、2:dx, dy out = torch.cat([heatmap, offset], dim=1) # 合并成 [B, 3, H, W] return out, count这个包装类解决的是黑匣子问题:不包装直接导出,计算图里可能会残留训练分支,输出个数、顺序都不确定。合并三通道是为了让 C# 端只处理一个输出张量,少一道张量拆分的麻烦。
3.2 完整导出脚本:torch.onnx.export 的 opset 与 dynamic_axes 参数
下面是一份完整的导出脚本,固定推理尺寸为 768x1024,只给 batch 留动态:
def export_onnx(ckpt_path, out_path="p2pnet.onnx"): from model import P2PNet # 按你的工程路径导入 model = P2PNet() state = torch.load(ckpt_path, map_location="cpu") 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_version=13, do_constant_folding=True, input_names=["input"], output_names=["output", "count"], dynamic_axes={ "input": {0: "batch"}, "output": {0: "batch"}, "count": {0: "batch"} } ) print(f"导出完成:{out_path}")几个参数值得说明。opset_version=13在 OnnxRuntime 里兼容面最大,老设备也不用担心算子缺失;do_constant_folding=True把能折叠的常量计算在导出阶段算完,减少运行时开销。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 (List<PointF> Points, int Count) Process(Bitmap bmp) { using var resized = ResizeWithPadding(bmp, _targetW, _targetH); var tensor = BitmapToTensor(resized, _targetW, _targetH); var inputs = new List<NamedOnnxValue> { NamedOnnxValue.CreateFromTensor(_inputName, tensor) }; using var results = _session.Run(inputs); var output = results.First(r => r.Name == _outputName).AsTensor<float>(); var countTensor = results.First(r => r.Name == _countName).AsTensor<float>(); var points = DecodePoints(output, bmp.Width, bmp.Height, _targetW, _targetH); int count = points.Count; return (points, count); } private DenseTensor<float> BitmapToTensor(Bitmap bmp, int w, int h) { var tensor = new DenseTensor<float>(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每帧耗时明显。归一化这里只做了除以 255,P2PNet 官方预处理通常就是 0~1 区间,如果你手里的训练脚本用了 ImageNet 均值方差,把对应数值替换进去即可。
4.3 后处理解码:局部最大抑制、坐标映射与 Count 一致性
_session.Run之后,output的布局是[1, 3, H, W],通道 0 是概率响应,通道 1、2 是 dx、dy。解码的核心是局部最大抑制——一个头在概率图上可能会形成几个相邻的强响应点,只用阈值过滤会数出好几个点,把 3x3 邻域内不是最大的点删掉,才能保证“一个头只算一次”:
private List<PointF> DecodePoints(DenseTensor<float> output, int origW, int origH, int targetW, int targetH) { int h = targetH, w = targetW; var list = new List<PointF>(); 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(DenseTensor<float> 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,我用List<PointF>而不是数组收集,因为点的数量在推理前不确定,动态集合在解码场景下更顺手。
后处理里有三个参数影响最大,我在现场调参就是围绕这张表来的:
| 参数 | 建议起始值 | 作用 | 调参方向 |
|---|---|---|---|
| 置信度阈值 | 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 模型数 MAE | 50 人场景误差 ≤3 人 | 阈值整体偏高或偏低 |
| count 输出 vs 点数 | 两者差 <2 | 解码通道布局解析错误 |
| 单帧 CPU 耗时 | 1024x768 ≤50ms | 输入尺寸偏大或 Session 反复创建 |
我习惯把count输出和points.Count同时打出来观察。如果两者差得大,说明解码逻辑和模型约定不一致,先检查通道顺序,再检查阈值。
6.2 把计数结果推到多客户端:TcpListener 的轻量广播
客流统计的消费端往往不止一个:大屏显示一个数字,后台系统要分钟级报表,值班室要看实时曲线。常见做法是用TcpListener接收多个客户端连接,把(timestamp, count, points)序列化成 JSON 广播出去。C# 的TcpListener天然支持多客户端管理,新客户端连接后单独开一个发送队列,计数事件触发时逐个写入。注意点位数据量大,广播时通常只发人数和热力图缩略坐标,不要每帧把几百个点全量推给所有客户端。
我最后养成的两个习惯,一是所有现场机器都保留一份基准帧的离线测试程序,排障时先跑离线再看实时;二是任何改模型的动作,第一件事不是看计数,而是看点位画出来准不准。点位准,数量自然准。希望帮到你。
本文还有配套的精品资源,点击获取