
簡介面向計算機視覺開發者與學生的人群計數實戰項目以OpenCV部署P2PNet算法輸出行人檢測與數量統計方案。P2PNet通過端到端回歸行人中心點關系完成計數對數據集質量依賴較低泛化能力較強適合智能視頻監控、公共安全管理中的實時人流統計與密度分析。壓縮包僅5個文件、約63.64MB包含Python版main.py、C版main.cpp、已轉換好的SHTechA.onnx模型、演示圖片以及README說明文檔兩套語言入口便于兼顧快速驗證與工程化部署項目結構清晰可對照示例圖片直接運行測試。讀者既能借此落地完整的人群計數系統也可通過源碼學習OpenCV與P2PNet的協作流程并遷移到自有數據場景。目前已有136人學習適合作為課程設計、畢業設計或工程落地的參考實現也適合希望快速落地人群計數算法或研究檢測原理的中高級讀者。1. 人群計數為什么用 OpenCV 部署 P2PNet 而不是用深度學習框架直接推理商場客流統計、地鐵閘機密度監測、工地安全帽區域人員密度考核這些場景都要求“數清楚畫面里到底有多少人”。基于密度圖的舊方法在密集人群下誤差很大而 P2PNet 把計數建模成人頭點預測直接輸出每個人頭的位置天然比密度積分好調也便于下游做越界、聚集判斷。更關鍵的是P2PNet 部署時可以完全繞開 PyTorch、TensorFlow 這些重依賴只需要 OpenCV DNN 讀取導出的 ONNX 模型再用 C 或 Python 跑前向。沒有獨立顯卡也能把 512×512 輸入的推理壓到幾十毫秒這是它能進入實際項目而不是停在論文里的原因。下面這條路徑從模型輸出約束開始一步步拆到閾值設置、視頻流處理和誤差評估適合已經在做 OpenCV 圖像處理項目、想加入人數統計能力的工程師。2. P2PNet 模型結構、輸出約定與 ONNX 導出要點2.1 P2PNet 為什么能把計數變成點回歸任務P2PNet 的骨干網絡通常基于 VGG16 特征提取后面接的不是目標檢測里的 anchor 框而是一組預測點。每個點包含置信度分數和坐標偏移。訓練階段使用點級監督把真實人頭位置作為正樣本相對密度圖方案省掉了判斷高斯核大小和積分誤差這兩個麻煩。推理階段拿到所有點后僅靠閾值過濾就能得到人數不需要再做密度積分所以預測誤差不會在積分時被放大。和經典檢測算法相比P2PNet 在擁擠場景下的優勢在于不需要針對每個人頭畫框也就不會有框回歸重疊導致的重復計數。人群密集時頭部尺寸小檢測框的 IoU 評判容易失真而點回歸只需要頭和真實位置之間的距離足夠小。這個特點決定了部署后處理也應該圍繞“點距離”來做而不是直接套用常規目標檢測的 NMS 框邏輯。2.2 導出 ONNX 模型時固定輸入尺寸和輸出布局P2PNet 官方訓練代碼以 PyTorch 為主部署前需要轉成 ONNX。常見做法是先固定輸入尺寸為 512×512再把模型切成“分類 坐標”兩個輸出分支。為了在 OpenCV DNN 里方便解碼我一般會把輸出統一設計成和輸入等大的特征圖pred_logits形狀為[1, 1, 512, 512]pred_points形狀為[1, 2, 512, 512]分別表示每個網格位置的置信度和該網格到真實人頭中心的相對偏移。轉換腳本如下import torch import torch.onnx def export_onnx(model, checkpoint_path, out_pathp2pnet.onnx): model.eval() checkpoint torch.load(checkpoint_path, map_locationcpu) model.load_state_dict(checkpoint[model_state_dict]) dummy_input torch.randn(1, 3, 512, 512) torch.onnx.export( model, dummy_input, out_path, input_names[input], output_names[pred_logits, pred_points], opset_version11, dynamic_axes{input: {0: batch}}, )這里把opset_version固定為 11是一個兼容性比較好的選擇。老版本 OpenCV 對更高 opset 里的動態 Shape 算子支持不完整強行用會報 unsupported ops 一類錯誤。固定輸入尺寸是為了讓輸出特征圖和輸入網格對齊坐標映射時可以按線性關系換算到原圖。dynamic_axes只開放 batch 維度需要時一次可以輸入 2 張以上圖片但單張推理不會產生額外開銷。需要留意的是P2PNet 的原生輸出不一定是這種等大特征圖布局。有的實現會把預測點組織成[N, 3]的張量前三列分別是 x、y、score。為了讓 OpenCV DNN 統一處理建議在導出前包一層 Resize 或 GridSample把所有輸出改成上面說的雙通道布局。這樣后面 Python 和 C 的解碼邏輯完全一致跨語言復現代價最低。2.3 OpenCV DNN 對 ONNX 的支持邊界OpenCV 讀取 ONNX 的能力隨版本變化比較大。OpenCV 4.x 從 4.2 開始支持 ONNX但像 Deformable Conv、GridSample 這類自定義算子經常導出失敗。P2PNet 骨干網絡是普通卷積和上采樣基本不會踩到算子坑但如果你在模型里加了注意力模塊要留意有沒有用到 einsum 這類容易被轉成復雜圖的運算。常見模型格式的支持情況如下模型格式OpenCV 支持情況說明ONNX推薦readNetFromONNX直接讀算子覆蓋率高適合 P2PNetTensorFlow PB可讀但不方便需要訓練側先凍結成凍結圖轉換步驟多TorchScript不支持OpenCV DNN 不認 TorchScript 導出產物在 Python 里讀取模型只有一行net cv2.dnn.readNetFromONNX(p2pnet.onnx)C 對應的調用是cv::dnn::readNetFromONNX(p2pnet.onnx)接口和 Python 對齊。讀到模型后后面所有步驟都統一用setInput和forward不再依賴任何深度學習訓練框架。3. 用 Python 在 OpenCV DNN 里跑通 P2PNet最小實現與參數拆解3.1 環境準備與常見 ModuleNotFoundError 排錯部署環境不復雜一個干凈的 Python 3.8 環境里安裝 OpenCV 和 NumPy 就夠python -m pip install opencv-python numpy有幾個高頻坑先說清楚。ModuleNotFoundError: No module named opencv這種報錯很常見因為 OpenCV 的 Python 模塊名是cv2而不是opencv安裝后 import 用的是import cv2。如果之前用pip install opencv那裝的是一個別的包正確命令是opencv-python。另外很多人遇到No module named cv2是因為系統里有多個 Python直接敲pip裝到了舊版本環境用python -m pip install可以避免。3.2 加載模型、預處理和推理預處理必須和 P2PNet 訓練時的數據歸一化保持一致。P2PNet 用 ImageNet 預訓練權重時標準做法是 BGR 轉 RGB 后對每個通道減均值再乘縮放系數。OpenCV 的blobFromImage把這幾步合并了參數順序容易搞混下面的例子可以直接用import cv2 import numpy as np IMG_W 512 IMG_H 512 MEAN (123.675, 116.28, 103.53) SCALE 0.00392156862745098 # 1/255 net cv2.dnn.readNetFromONNX(p2pnet.onnx) image cv2.imread(crowd.jpg) H, W image.shape[:2] blob cv2.dnn.blobFromImage( image, scalefactorSCALE, size(IMG_W, IMG_H), meanMEAN, swapRBTrue, cropFalse, ) net.setInput(blob) logits, points net.forward([pred_logits, pred_points])這里的執行順序是先按swapRBTrue把 BGR 轉成 RGB然后執行blob (pixel - MEAN) * SCALE。MEAN用的是 ImageNet 均值乘以 255SCALE用 1/255 才能把像素值縮回 0 到 1 范圍與 PyTorch 訓練時 Normalize 行為對應。cropFalse表示等比放縮并填充不會對圖片做中心裁剪保留完整人群信息。3.3 解碼從特征圖到真實坐標模型返回的logits和points都是四維張量但 OpenCV 的forward返回的 NumPy 數組已經去掉了 batch 維度以外的信息實際拿到的是[1, 1, H, W]和[1, 2, H, W]。需要先squeeze()去掉前面兩個維度然后按分類頭算分數、按回歸頭算偏移logits np.squeeze(logits) pts np.squeeze(points) scores 1.0 / (1.0 np.exp(-logits)) threshold 0.5 ys, xs np.where(scores threshold) scale_x W / IMG_W scale_y H / IMG_H candidates [] for x, y in zip(xs, ys): score scores[y, x] offset_y pts[0, y, x] offset_x pts[1, y, x] grid_x (x offset_x) * scale_x grid_y (y offset_y) * scale_y candidates.append((grid_x, grid_y, score)) print(原始預測點數:, len(candidates))threshold0.5是常用起點但實戰中不一定要固定。密集場景如果大量遮擋導致置信度偏低調到 0.3 能多召回一些人頭同時也會帶來誤檢需要結合最終誤差評估決定。這里的pts[0]和pts[1]分別表示 y 偏移和 x 偏移這個順序取決于導出模型時cv::dnn::blobFromImage的輸入布局如果你的訓練代碼里回歸頭輸出是 x 在前就調換一下。3.4 距離去重而不是用檢測框 NMSP2PNet 的預測點在理想情況下一個頭只出一個點但實際在互相遮擋的邊緣會出現多個點聚成一簇。目標檢測慣用的cv2.dnn.NMSBoxes需要把點轉成框而且框寬高不好定。人群場景下人頭大小接近直接按像素距離去重更簡單order np.argsort([c[2] for c in candidates])[::-1] keep [] min_dist 5.0 for i in order: cur candidates[i] duplicated False for k in keep: if abs(cur[0] - k[0]) min_dist and abs(cur[1] - k[1]) min_dist: duplicated True break if not duplicated: keep.append(cur) count len(keep) print(過濾后人數:, count)按置信度從高到低排序先保留高分點再丟掉距離它過近的低分點。min_dist5.0是輸入特征圖尺度下的經驗值在 512×512 輸入上大約相當于原圖 5 到 10 個像素。如果原圖是 1080p建議把它換算到原圖尺度也就是設成min_dist * max(scale_x, scale_y)。3.5 可視化計數結果最后把保留的點畫回原圖便于肉眼檢查for x, y, score in keep: cv2.circle(image, (int(x), int(y)), 3, (0, 0, 255), -1) cv2.putText(image, f{score:.2f}, (int(x) 5, int(y) - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 0, 0), 1) cv2.putText(image, fcount: {count}, (20, 40), cv2.FONT_HERSHEY_SIMPLEX, 1.2, (0, 255, 0), 2) cv2.imwrite(result.jpg, image)這個可視化結果能直接暴露兩類問題一是點星羅棋布但沒有匯聚到頭中央說明回歸偏移沒校準二是同一個頭周圍出現兩三個彼此距離很近的點說明距離閾值設得太小。看輸出圖調參比只看數字快得多。4. 用 C 部署 P2PNet加載、輸出解析與內存管理4.1 CMake 工程組織C 版本更依賴 OpenCV 的頭文件和庫路徑用 CMake 管理最省心。下面是最小工程結構cmake_minimum_required(VERSION 3.10) project(p2pnet_cpp) set(CMAKE_CXX_STANDARD 14) find_package(OpenCV REQUIRED COMPONENTS core imgproc dnn imgcodecs) add_executable(p2pnet_demo main.cpp) target_link_libraries(p2pnet_demo ${OpenCV_LIBS})這里顯式把dnn列為必選組件避免編譯通過但運行時提示 OpenCV 沒有啟用 DNN 模塊。如果 OpenCV 是用源碼編譯的需要在編譯時打開BUILD_opencv_dnn直接下載預編譯包則通常都包含。4.2 C 讀取模型和圖片C 接口和 Python 幾乎一一對應但注意blobFromImage的返回類型是cv::Mat輸入尺寸在Scalar中需要顯式寫 float。示例代碼#include opencv2/opencv.hpp #include opencv2/dnn.hpp #include iostream #include cmath #include vector using namespace cv; using namespace dnn; int main() { Net net readNetFromONNX(p2pnet.onnx); Mat image imread(crowd.jpg); if (image.empty()) { std::cerr failed to load image std::endl; return -1; } int H image.rows; int W image.cols; Mat blob blobFromImage( image, 0.00392156862745098, Size(512, 512), Scalar(123.675, 116.28, 103.53), true, false ); net.setInput(blob); std::vectorMat outs; std::vectorString outNames {pred_logits, pred_points}; net.forward(outs, outNames);這里image.cols是寬image.rows是高很多 C 新手在循環里把cols當高度用會導致坐標映射錯位。net.forward(outs, outNames)會把兩個輸出按指定名字填充到outs[0]和outs[1]。4.3 C 解碼輸出線性遍歷比 Multi-Dim 訪問更穩OpenCV 的Mat本身是四維對象但直接用outs[1].atfloat(0, 0, y, x)在部分版本上性能較差而且維度順序容易記錯。我一般直接拿data指針按線性偏移訪問因為四維張量在內存里是連續排布的batch 最外層channel 次外層y 和 x 最內層。pred_points的布局可以解釋成 channel 0 是 y 偏移、channel 1 是 x 偏移每個 channel 里是按行優先排列的 512×512 數據。float* logits_data (float*)outs[0].data; float* points_data (float*)outs[1].data; const int SIDE 512; const int VOL SIDE * SIDE; const float threshold 0.5f; const float min_dist 5.0f; std::vectorcv::Point3f candidates; for (int y 0; y SIDE; y) { for (int x 0; x SIDE; x) { float score 1.0f / (1.0f std::exp(-logits_data[y * SIDE x])); if (score threshold) continue; float offset_y points_data[y * SIDE x]; float offset_x points_data[VOL y * SIDE x]; float real_x (x offset_x) * (W / (float)SIDE); float real_y (y offset_y) * (H / (float)SIDE); candidates.emplace_back(real_x, real_y, score); } }points_data的索引VOL y * SIDE x表示 channel 1 里的同一位置。如果你的導出順序是 channel 0 存 x 偏移就把這里對調。線性遍歷的好處是避免四維at的大括號寫法還方便直接計算總元素數做數組越界排查。4.4 距離去重和計數C 側的距離去重邏輯與 Python 完全一致按分數降序貪心選擇std::sort(candidates.begin(), candidates.end(), [](const cv::Point3f a, const cv::Point3f b) { return a.z b.z; }); std::vectorcv::Point3f keep; for (const auto cur : candidates) { bool duplicated false; for (const auto k : keep) { if (std::abs(cur.x - k.x) min_dist std::abs(cur.y - k.y) min_dist) { duplicated true; break; } } if (!duplicated) keep.push_back(cur); } std::cout count: keep.size() std::endl; for (const auto p : keep) { cv::Rect block_rect((int)p.x - 2, (int)p.y - 2, 4, 4); rectangle(image, block_rect, Scalar(0, 0, 255), 1); } imwrite(result_cpp.jpg, image); return 0; }用cv::Rect在預測點位置畫標記塊Rect的四個參數分別是 x、y、寬、高對應cols和rows時不要寫反。Point3f的 z 字段臨時存放置信度這樣排序時一個結構體就夠了。4.5 Python 與 C 接口對照操作PythonC讀取模型cv2.dnn.readNetFromONNXcv::dnn::readNetFromONNX創建 blobcv2.dnn.blobFromImagecv::dnn::blobFromImage前向推理net.forward(names)net.forward(outs, outNames)輸出張量NumPy ndarraycv::Mat用.data訪問推理優化開關cv2.setUseOptimized(True)cv::setUseOptimized(true)Python 適合快速驗證和后處理迭代C 適合集成到視頻分析服務或邊緣設備。兩者解碼邏輯完全同構只要在導出 ONNX 時固定好輸出布局遷移成本基本為零。5. 從“能跑”到“能上線”精度調優、視頻流和推理加速5.1 輸入分辨率決定計數上限512×512 是顯存和速度的平衡點但在人群特別密集的圖片上很多人頭只占十幾個像素特征圖下采樣后可能只剩一個點模型會漏檢。遇到這種場景把輸入分辨率提到 640×640 或 768×768效果提升非常直觀。代價是推理時間按面積線性增長CPU 上的耗時可能從 40ms 漲到 90ms 以上。如果項目對精度要求高我一般會在測試集上同時跑 512 和 640 兩檔比較 MAE 后再決定是否接受耗時增加。5.2 用統一腳本測推理耗時OpenCV DNN 的第一次推理通常包含模型解析和內存分配時間不能代表真實水平。正確做法是先 warm up 幾次再取多輪平均值import time # 先跑 5 次預熱讓模型內部的中間緩存分配完成 for _ in range(5): net.setInput(blob) net.forward() t0 time.perf_counter() runs 30 for _ in range(runs): net.setInput(blob) net.forward() avg_ms (time.perf_counter() - t0) * 1000 / runs print(f平均推理耗時: {avg_ms:.1f} ms)這時候如果發現耗時比預期高很多先檢查 CPU 是否開啟了睿頻再檢查 OpenCV 是否啟用了 IPP 優化。在 C 里還要確認編譯時用的是 Release 模式Debug 模式下 DNN 性能會差一個數量級。5.3 視頻流里做計數VideoCapture 與跳幀策略實時視頻里每一幀都跑 P2PNet 沒有必要人群在相鄰幀之間的位置變化很小。OpenCV 的VideoCapture打開本地相機或 RTSP 流的核心原理是先grab()從設備緩沖區取下一幀再用retrieve()解碼成Mat。跳幀時不需要每幀都retrieve只對要計數的幀解碼就行cap cv2.VideoCapture(rtsp://user:pwd192.168.1.10/stream) frame_id 0 skip_frames 3 while True: ret cap.grab() if not ret: break if frame_id % skip_frames ! 0: frame_id 1 continue ret, frame cap.retrieve() result_count, result_img count_and_draw(frame, net) frame_id 1grab()和retrieve()分開調能顯著減少視頻解碼壓力尤其對 RTSP 網絡流有效。實際項目里不要用frame_id % skip_frames 0這種方式判斷因為網絡流偶爾丟幀后計數會錯位應該用獨立計數器frame_id再取模。5.4 閾值協同調整置信度閾值和后處理距離閾值不是獨立的。調低threshold會引入更多低分預測點這時如果距離閾值不放大同一個頭可能保留多個點計數反而變高。建議每次調threshold后都看一遍可視化結果圖重點檢查“一個頭兩個點”和“背景處多一個點”兩類錯誤。經驗規律是threshold每降低 0.1min_dist相應增大 1 到 2 個像素。5.5 OpenCV 版本與模型兼容性排錯OpenCV DNN 對 ONNX 的算子支持一直在變。如果你在導出后遇到Unknown layer type或Unsupported ops先確認 OpenCV 版本是不是太老升級到 4.8 以上能解決大部分算子問題。另一個隱蔽問題是代碼里同時存在多個 OpenCV 版本比如系統裝了一個 4.2Python 環境里 pip 又裝了一個 4.9C 編譯鏈接到了系統庫運行時卻加載到別的庫會直接報symbol lookup error。驗證當前實際版本用cv2.__version__或 C 的CV_VERSION不要只看編譯日志。6. 驗證計數結果用自己的圖片復現并量化誤差ONNX 模型能跑通不代表部署正確尤其要驗證預處理參數和后處理坐標映射是否引進偏差。最有效的方法是準備一張有 ground truth 人頭的圖片把預測點數和逐點位置與標注對比。為了量化誤差把計數結果保存成 JSON 格式然后用一個小腳本統一評估。先定義一個簡單的評估入口把前面 Python 解碼邏輯封裝成函數count_head(image_path, net)它返回預測人數和坐標列表。再準備 ground truth 文件格式可以是圖片名映射到真實人數import json import glob import os import cv2 import numpy as np def evaluate(model_path, image_dir, gt_path): net cv2.dnn.readNetFromONNX(model_path) with open(gt_path, encodingutf-8) as f: gt_data json.load(f) preds [] gts [] detail [] for image_path in sorted(glob.glob(os.path.join(image_dir, *.jpg))): name os.path.basename(image_path) count, points count_head(image_path, net) gt_count gt_data.get(name, -1) if gt_count 0: continue preds.append(count) gts.append(gt_count) detail.append((name, gt_count, count)) preds np.array(preds, dtypenp.float32) gts np.array(gts, dtypenp.float32) mae np.mean(np.abs(preds - gts)) rmse np.sqrt(np.mean((preds - gts) ** 2)) print(fMAE{mae:.2f}, RMSE{rmse:.2f}) for name, gt_count, pred_count in detail[:10]: print(f{name}: gt{gt_count}, pred{pred_count}, diff{pred_count - gt_count})MAE是平均絕對誤差RMSE對大誤差更敏感。如果MAE在 5 以內通常可以接受超過 10 就要復盤預處理或閾值。一個實用技巧是看逐張明細里誤差的符號絕大多數樣本是負數說明漏檢為主調低置信度閾值正數說明誤檢為主調高閾值或增加距離去重范圍。最后還要做一次端到端復現用圖片集中同樣一張圖分別跑 Python 版和 C 版比較兩者輸出人數是否完全一致。若差一個點多半是blobFromImage的mean類型寫成了整數或者 C 排序函數寫成了穩定排序影響同分點取舍。把下面的差量檢查加在你的測試腳本里python_cpp_diff 0 # 跑完兩邊后手動比對 assert python_cpp_diff 0, Python 與 C 輸出不一致檢查預處理或閾值類型這行斷言適合作為 CI 或發布前冒煙測試保證兩個版本在相同閾值下行為一致。人群計數部署真正容易翻車的不是模型結構而是這些跨語言、跨版本的對齊細節。本文還有配套的精品資源點擊獲取