简介:面向C#开发者的P2PNet人群检测与计数完整工程源码,基于ONNX Runtime加载预训练模型,可在Visual Studio中直接编译运行,适用于安防监控、公共活动管理、商场客流统计等场景的实时人群计数需求。压缩包共77个文件,约84.29MB,涵盖C#源码、Visual Studio解决方案与工程配置(sln/csproj)、预训练ONNX模型、OpenCvSharp与OnnxRuntime依赖库(dll/pdb/exe)以及测试图片等资源,目录结构完整,便于按模块查阅与二次开发。核心代码采用WinForms界面,覆盖图像预处理、模型推理、边界框后处理与可视化全流程,由主窗体和结果显示窗体构成,可直接在真实图片上直观验证检测与计数效果。已有670人学习下载,附带的测试图片可让下载后即刻复现输出效果,省去自行准备数据的麻烦;对于希望将深度学习模型集成到C#桌面应用的开发者而言,是一份难得的可运行参考工程,有助于快速上手ONNX落地实践。
1. C# Onnx P2PNet人群检测与计数:一套能直接跑通的推理源码
如果你在 C# 里做过 ONNX 模型部署,大概率经历过这种尴尬:PyTorch 训练好的模型在 Python 里跑得飞起,一换到 C# 就卡在图像预处理、Tensor 构造和输出解析上。这份 P2PNet 人群检测与计数源码正好把这条链路完整打通了——从 Visual Studio 打开解决方案、加载 ONNX 模型、送入图像,到输出检测框和人数,全程 C# 实现,不需要装 Python 环境。P2PNet 是专门做人群计数的模型,核心思路是直接回归点的位置和密度,和 YOLO 这类框检测模型不同,它输出的是每个人的中心点坐标和置信度,再通过阈值筛选得到人数。这套源码对做安防监控、商场客流统计、或者是想学 C# 调 ONNX Runtime 的开发者都适用,尤其是那些想在 Windows 桌面程序里嵌入人群计数能力的人。
2. 从 Onnx_Demo.sln 拆包:项目结构与三类关键文件
2.1 解决方案文件与工程配置
拿到压缩包解开之后,首先看到的是Onnx_Demo.sln,这是 Visual Studio 的解决方案入口。双击打开之前,建议先确认你本机的 Visual Studio 版本——这个解决方案文件用的是 VS 2022 的格式,如果你本机装的是 VS 2019 或者更早版本,可能会提示升级或者无法加载,这时候直接右键用记事本打开 .sln 文件,把Format Version对应的版本号改低一档就能兼容了。
Onnx_Demo.csproj是工程文件,里面配置了项目的目标框架、引用程序集和编译选项。我打开之后看到它默认配置了 x86 和 x64 两种平台,这很关键,因为 ONNX Runtime 和 OpenCvSharp 的原生 DLL 是分架构的。如果你本机是 64 位系统,编译时务必把解决方案平台切到 x64,否则运行时会直接报BadImageFormatException。
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'"> <PlatformTarget>x64</PlatformTarget> </PropertyGroup>这段配置在 csproj 文件里指定了 x64 平台的目标架构。如果不显式指定,默认 AnyCPU 在加载 x64 原生 DLL 时会出问题。我自己在部署到工控机时遇到过几次这种坑,后面会在避坑章节细说。
2.2 五个核心代码文件与两个窗体
这个项目的代码组织不复杂,核心文件就这么几个:Common.cs、frmMain.cs、frmShow.cs、CrowdPoint.cs和Program.cs。Program.cs是程序入口,标准的 WinForms 启动方式,没什么好说的。frmMain.cs是主窗体,负责加载模型、选择图片、触发推理;frmShow.cs是结果展示窗体,把绘制了检测结果的图像显示出来;Common.cs应该是一些公共方法或者常量定义;CrowdPoint.cs这个类名很有意思——P2PNet 的输出本质就是一组点,这个类大概率是封装了单个人的点坐标和置信度的数据模型。
两个窗体的分工值得注意。frmMain管流程,frmShow管展示,这种拆分方式比在一个窗体里又选图又画结果要清晰得多。如果你打算在此基础上扩展成实时视频流检测,把 frmShow 换成一个 PictureBox 定时刷新就够用了。
2.3 运行时依赖:onnxruntime 和 OpenCvSharp 的角色
依赖项在压缩包里直接带着了,省去了折腾 NuGet 的功夫。onnxruntime.dll和onnxruntime_providers_shared.dll是 ONNX Runtime 的核心与共享库,负责加载SHTechA.onnx模型文件并执行推理。OpenCvSharp.dll和OpenCvSharpExtern.dll是 OpenCvSharp 的托管层和原生层,做图像读取、缩放、颜色转换这些操作用。
这里要插一句,SHTechA.onnx是 P2PNet 在 ShanghaiTech 数据集 Part A 上训练出来的权重,这个数据集的特点是场景密集、人群分布复杂,模型的泛化能力对稀疏场景也能应付,但如果你要检测的图片是无人机俯拍的极密集人群,建议换用更适配的权重。
Newtonsoft.Json.dll大概率是给模型配置或者结果序列化用的,System.Memory.dll、System.Buffers.dll这些是 .NET 运行时的依赖,正常编译会自动引用,不用手动管。
3. P2PNet 推理全链路:从 Bitmap 到人群计数
3.1 ONNX Runtime 会话创建与输入输出绑定
整个推理链路的第一步是创建 InferenceSession。ONNX Runtime 的 C# API 封装得很友好,核心就是InferenceSession这个类。加载模型的时候可以指定执行提供程序,默认是 CPU,如果你的机器有 NVIDIA 显卡并装了 CUDA 版 ONNX Runtime,可以用SessionOptions.AppendExecutionProvider_CUDA()切换到 GPU 推理。
using Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime.Tensors; var sessionOptions = new SessionOptions(); sessionOptions.AppendExecutionProvider_CPU(); // 如果你的 ONNX Runtime 是 GPU 版本,可以改成 AppendExecutionProvider_CUDA var session = new InferenceSession("model/SHTechA.onnx", sessionOptions); var inputMeta = session.InputMetadata; var outputMeta = session.OutputMetadata;创建 session 之后,我一般会立刻读取InputMetadata和OutputMetadata,确认模型的输入输出张量形状。P2PNet 的输入通常是[1, 3, H, W]的 float 张量,输出有两个:一个是预测的点坐标回归结果,一个是分类置信度。具体维度要看训练时怎么定义的,用上面的代码打印出来一目了然。这一步不能省,因为很多人拿到模型就直接猜形状,猜错了后处理全是乱的。
3.2 图像预处理:缩放、归一化和 Tensor 构造
P2PNet 训练时图像一般会做标准化,ImageNet 的 mean 和 std 那套。OpenCvSharp 读取图像后默认是 BGR 通道顺序,而模型训练时用的是 RGB,这个通道顺序不一致会导致检测结果明显变差。我一般在预处理里先CvtColor转成 RGB,再归一化,最后构造 Tensor。
using OpenCvSharp; Mat mat = Cv2.ImRead("test_img/1.jpg", ImreadModes.Color); Cv2.CvtColor(mat, mat, ColorConversionCodes.BGR2RGB); int targetWidth = 640; int targetHeight = 480; Cv2.Resize(mat, mat, new Size(targetWidth, targetHeight)); float[] mean = { 0.485f, 0.456f, 0.406f }; float[] std = { 0.229f, 0.224f, 0.225f }; var dimensions = new[] { 1, 3, targetHeight, targetWidth }; var tensorData = new float[3 * targetHeight * targetWidth]; unsafe { byte* ptr = (byte*)mat.Data; for (int y = 0; y < targetHeight; y++) { for (int x = 0; x < targetWidth; x++) { for (int c = 0; c < 3; c++) { float pixel = ptr[(y * targetWidth + x) * 3 + c]; int index = c * targetHeight * targetWidth + y * targetWidth + x; tensorData[index] = (pixel / 255.0f - mean[c]) / std[c]; } } } } var tensor = new DenseTensor<float>(tensorData, dimensions);这段代码的关键在像素索引计算:c * targetHeight * targetWidth + y * targetWidth + x,这是因为 ONNX 模型的输入布局是 NCHW,也就是通道维在最前面。很多人直接把 HWC 的数据拍扁塞进去,结果模型输出完全不对。DenseTensor<float>的构造函数接收一维数组和形状数组,两个参数缺一不可。
提示:如果你不想开unsafe代码块,可以用mat.GetArray(out byte[] data)拿到完整的字节数组,再按同样的索引逻辑填充,性能略低但更安全。
3.3 推理执行与输出张量解析
预处理完成后,把 Tensor 放进List<NamedOnnxValue>传入session.Run(),拿到IDisposableReadOnlyCollection<DisposableNamedOnnxValue>结果。P2PNet 的输出解析是整条链路里最容易翻车的地方。
var inputs = new List<NamedOnnxValue> { NamedOnnxValue.CreateFromTensor("input", tensor) }; using (var results = session.Run(inputs)) { var output = results.First().AsTensor<float>(); // 假设输出形状是 [1, N, 2](点坐标)或 [1, N, 1](置信度) var predictions = output.ToArray(); int totalCount = 0; foreach (var value in predictions) { if (value > 0.5f) // 置信度阈值 { totalCount++; } } }这段后处理逻辑是简化的示意。真实情况中 P2PNet 的输出有两个头:一个回归点坐标,一个输出置信度,你需要分别从 results 里按输出名称索引。常见的错误是直接results.First(),如果输出顺序变了,拿到的张量可能是坐标而不是置信度,计数结果直接翻车。我在下面的避坑章节会专门说这个。
3.4 CrowdPoint.cs 的数据结构作用
CrowdPoint.cs这个类在这个项目里承担了「把模型输出转成业务数据」的职责。我理解它的结构大概长这样:
public class CrowdPoint { public float X { get; set; } public float Y { get; set; } public float Score { get; set; } }坐标 X、Y 对应模型输出的归一化坐标,Score 是置信度。在 WinForms 的 PictureBox 上画点的时候,需要把归一化坐标乘以图像的显示宽度和高度。很多人忽略这一步,直接在像素坐标上画,画出来的点全部偏到左上角。拿 CrowdPoint 类做一层转换,代码逻辑会清爽很多,测试也好写。
4. 避坑指南:P2PNet 在 C# 落地时最常踩的六个坑
4.1 杀软误删 onnxruntime.dll
现象:程序刚启动就报DllNotFoundException,检查 bin 目录发现onnxruntime.dll不见了。
原因:Windows Defender 或者第三方杀毒软件会把 ONNX Runtime 的原生 DLL 当作可疑文件直接隔离。这个情况在工控机上尤其常见,很多工厂的电脑装了各种安全软件。
解决:在杀毒软件里加白名单目录,把项目输出目录整个加进去。如果是 Defender,在「病毒和威胁防护」→「排除项」里添加 bin 目录路径。另外建议把 DLL 复制到C:\Program Files\你的程序名这种固定安装目录,而不是直接放在桌面运行。
4.2 x86/x64 不匹配导致 BadImageFormatException
现象:编译通过,一运行就抛System.BadImageFormatException。
原因:ONNX Runtime 的onnxruntime.dll有 x86 和 x64 两个版本,压缩包里两个平台目录都放了。如果你的工程是 AnyCPU,运行时可能加载了错误架构的 DLL。
解决:打开 Visual Studio 的「配置管理器」,把活动解决方案平台改成 x64,然后在 csproj 里显式加<PlatformTarget>x64</PlatformTarget>。改完之后 Clean Solution 再重新 Build,别用增量编译,因为旧的目标文件可能还留在 bin 目录里。
4.3 C# 调用 OpenCvSharp 原生层出现 Access Violation(c0000005)
现象:程序运行到Cv2.Resize或者Cv2.CvtColor时崩溃,事件查看器里记录c0000005 access violation。
原因:这是 C# 调用 C++ 原生库最容易遇到的问题。OpenCvSharpExtern.dll 是 native 库,当传入的 Mat 对象被 GC 回收但仍然有非托管引用时,就会访问到野指针。更常见的是 Mat 的Data指针在unsafe代码块中读取时,Mat 被 GC 移动了。比如你写Cv2.ImRead后不做任何引用保持,直接在 lambda 里访问 Data 指针,就可能踩到这个。
解决:处理 Mat 时确保它在整个 unsafe 操作期间不会被 GC 回收,可以用GC.KeepAlive(mat)显式保持引用,或者干脆在fixed语句里固定指针。我个人的习惯是尽量不用 unsafe 操作 Mat.Data,而是用mat.GetArray()把数据拷贝到托管数组再处理,虽然多了 20% 的拷贝开销,但稳定性高出很多。
4.4 P2PNet 输入尺寸和训练尺寸不一致
现象:模型跑出来了,但检测框或者点全部是乱的,计数结果忽大忽小。
原因:P2PNet 在训练时可能用了固定尺寸,比如[1, 3, 768, 1024],你按 640x480 输入,宽高比变了,模型输出的坐标语义就完全乱掉。这不是 C# 的问题,是模型本身的输入约束。
解决:在代码里先读取inputMeta的 Dimensions,确认模型的期望尺寸,然后等比缩放 + padding,而不是直接拉伸。等比缩放后,短边补 0 或者补 114(ImageNet 的灰色像素值),保证输入严格等于模型训练时的形状。
4.5 输出张量维度解析错误
现象:计数结果是一个固定的奇怪数字,比如永远输出 0 或者永远输出图像总像素数。
原因:P2PNet 的输出头不止一个,有的输出是坐标回归(形状[1, N, 2]),有的是置信度(形状[1, N, 1]),甚至有的版本还有密度图分支。你用results.First()拿到的可能是坐标张量,对坐标张量做阈值过滤,当然什么都过滤不出来。
解决:写一行代码把所有输出的名称和形状打印出来:
foreach (var name in outputMeta.Keys) { Console.WriteLine($"{name}: {string.Join(",", outputMeta[name].Dimensions)}"); }然后根据打印结果,用输出名称去检索对应的张量,而不是用 First。另外注意输出 Tensor 的维度可能带动态轴(比如[1, ?, 2]),?表示运行时才知道的点数,这很正常,别硬编码成固定值。
4.6 frmShow 窗体显示黑图
现象:推理正常,计数结果也有,但显示窗体里全黑。
原因:在把推理结果画到 Mat 上之后,没有调用Cv2.ImShow或者 WinForms 的 PictureBox 刷新。另外 OpenCvSharp 的 Mat 是 BGR 顺序,如果你画完直接转换成 Bitmap 显示,RGB 顺序颠倒会让图像看起来偏蓝偏暗,但不至于全黑。全黑大概率是 Mat 数据在异步操作中被释放了。
解决:frmShow窗体里用一个PictureBox,在设置Image属性之前先做Mat.Clone(),确保 Mat 生命周期不被 GC 回收。显示时用OpenCvSharp.Extensions.BitmapConverter.ToBitmap(mat),如果发现颜色偏色,就在画图之前CvtColor到 RGB。
5. 进阶:把自己的模型塞进这套框架
5.1 替换模型文件与自适应输入输出
这套代码写死了model/SHTechA.onnx这个路径,但实际使用中你大概率要换成自己训练的 P2PNet 权重,或者是另一个 PyTorch 转 ONNX 的人群检测模型。替换模型不能只改文件名,还需要动态适配输入输出。
var inputName = inputMeta.Keys.First(); var inputShape = inputMeta[inputName].Dimensions; int inputHeight = inputShape[2]; int inputWidth = inputShape[3];这里通过读取元数据拿到模型的期望输入尺寸,再去调整预处理逻辑。模型输出名称也可以这样遍历获取,构建一个输出名称列表。这样不管 ONNX 是从什么框架导出的,都能在这套框架里跑起来。
5.2 耗时统计与性能基准
推荐在推理前后加Stopwatch统计,分清三个时间:预处理耗时、推理耗时、后处理耗时。CPU 上跑 SHTechA.onnx 的推理耗时通常在 50 到 200 毫秒之间,取决于分辨率和 CPU 性能。如果发现预处理耗时超过推理耗时,优先检查是不是在每次推理时都重新创建了 Tensor 数组;如果后处理耗时高,看看是不是用了ToArray()做了大量不必要的表达。
var sw = System.Diagnostics.Stopwatch.StartNew(); // 推理代码 sw.Stop(); Console.WriteLine($"Inference: {sw.ElapsedMilliseconds} ms");5.3 验证结果的一个习惯
我从这套源码里学到的最有价值的一件事是:每次改后处理逻辑,都要拿着同一张测试图跑,把输出的坐标值打印出来比对。P2PNet 论文里在 ShanghaiTech Part A 上 MAE 是 4.5 左右,但那是分辨率很高的原始图。我换成摄像头画面之后,前几次计数结果偏差很大,核对之后发现是预处理时用了错误的 mean/std 值。从那以后我每次换数据源,都强制走一遍「预处理参数核对 → 单张图打印输出 → 再改代码」的流程,这个习惯帮我避免了很多次「模型部署成功了但效果很怪」的问题。希望这套源码和上面这些经验能帮你的落地项目少走几步弯路。
本文还有配套的精品资源,点击获取