識別:GNN傳播源碼解析)
簡介一份面向人工智能本科畢業(yè)設(shè)計的步態(tài)識別多目標(biāo)跨鏡頭跟蹤檢測系統(tǒng)源碼基于YOLOv5DeepSORT完成目標(biāo)檢測與跟蹤結(jié)合GaitSet算法實現(xiàn)步態(tài)特征識別適用于監(jiān)控場景下的跨鏡頭多目標(biāo)持續(xù)追蹤研究。資源共343個文件以173個Python腳本為核心算法實現(xiàn)52個YAML文件用于模型與訓(xùn)練配置另有Markdown說明文檔、Shell部署腳本、C/CUDA擴展模塊等覆蓋從數(shù)據(jù)預(yù)處理、模型訓(xùn)練到推理部署的完整鏈路壓縮包僅22.23MB結(jié)構(gòu)清晰便于查閱。已有4449人學(xué)習(xí)下載。適合本科畢業(yè)設(shè)計、課程項目或算法研究者參考可幫助理解跨鏡頭跟蹤與步態(tài)識別的工程化實現(xiàn)包含完整源碼、配置文件與部署腳本能夠直接作為實驗基礎(chǔ)或二次開發(fā)起點。1. yolov5DeepSORTGaitSet跨鏡頭步態(tài)識別這個題目到底在解決什么問題跨鏡頭跟蹤是監(jiān)控場景里最尷尬的一環(huán)。單鏡頭內(nèi)的多目標(biāo)跟蹤早就有成熟方案一旦目標(biāo)走出畫面再進入另一路攝像頭ID 就斷了。最直接的補救是換裝重識別但監(jiān)控場景下人臉看不清、衣服換個角度就變樣真正穩(wěn)定的是步態(tài)——人走路的姿態(tài)很難刻意偽裝。這套畢業(yè)設(shè)計源碼做的就是這件事用 yolov5 做目標(biāo)檢測DeepSORT 做單鏡頭內(nèi)的跟蹤再用 GaitSet 提取步態(tài)特征做跨鏡頭匹配整個鏈路里最核心的 GNN 傳播模塊也以源碼形式放在包里。如果你正在做人臉、行人重識別或多目標(biāo)跟蹤相關(guān)的課設(shè)、競賽或者想快速拿一套能跑的跨鏡頭跟蹤基線這份資源值得先看目錄再動手。2. 三段式架構(gòu)檢測、單鏡頭跟蹤、跨鏡頭重識別怎么接2.1 yolov5 檢測模塊不是拿來就跑先確認(rèn)輸出格式這套系統(tǒng)的第一層是 yolov5。檢測模塊負(fù)責(zé)把每一幀畫面里的行人框出來輸出的是x1, y1, x2, y2, conf, cls這種格式的張量。常見的做法是用 yolov5s 或者你自己訓(xùn)練的權(quán)重推理時把檢測結(jié)果直接喂給 DeepSORT。需要注意一點yolov5 輸出的坐標(biāo)是像素坐標(biāo)DeepSORT 的輸入要求也是像素坐標(biāo)不需要歸一化但需要確保檢測框沒有被 NMS 二次壓縮過。# 以 YOLOv5 的 detect 輸出為例做檢測結(jié)果到跟蹤器的對接 import cv2 import torch model torch.hub.load(ultralytics/yolov5, yolov5s, pretrainedTrue) def detect_frame(frame): results model(frame, size640) dets results.xyxy[0].cpu().numpy() # [x1, y1, x2, y2, conf, cls] person_dets dets[dets[:, 5] 0] # COCO 類別 0 是 person return person_dets[:, :5] # 只要坐標(biāo)和置信度這里的size640是推理尺寸可以用imgsz參數(shù)覆蓋person_dets[:, :5]之所以只要前五列是因為 DeepSORT 的update()接口接收的是[x1, y1, x2, y2, score]數(shù)組。如果你用的是自定義數(shù)據(jù)集COCO 類別索引就不是 0 了需要在類別映射表里找到 person 對應(yīng)的索引否則跟蹤器會把車和樹都當(dāng)成目標(biāo)去跟蹤。2.2 DeepSORT 跟蹤層卡爾曼濾波和匈牙利匹配的參數(shù)含義DeepSORT 是整個系統(tǒng)里承上啟下的部分。它接收 yolov5 的檢測框為每個目標(biāo)維護一個軌跡狀態(tài)內(nèi)含卡爾曼濾波預(yù)測的位置、速度、外觀特征。核心參數(shù)有三個max_dist控制外觀特征的最大余弦距離max_iou_dist控制匹配時 IoU 的閾值max_age決定軌跡丟失多少幀后刪除。畢設(shè)場景下這三個參數(shù)往往被忽略直接用默認(rèn)值但換數(shù)據(jù)集后它們是最影響跟蹤穩(wěn)定性的。from deep_sort_realtime.deepsort_tracker import DeepSort tracker DeepSort( max_dist0.2, # 余弦距離閾值越小越嚴(yán)格 max_iou_dist0.7, # IoU 匹配閾值 max_age30, # 軌跡丟失多少幀后刪除 nn_budget100, # 外觀特征樣本上限 embeddermobilenet # 提取外觀特征的網(wǎng)絡(luò) ) def update_tracks(frame, detections): tracks tracker.update_tracks(detections, frameframe) return [(t.track_id, t.to_ltrb()) for t in tracks if t.is_confirmed()]max_dist0.2的意思是外觀特征余弦距離大于 0.2 的檢測框和軌跡不會匹配這個值在光線變化大的監(jiān)控場景下往往需要放寬到 0.3。nn_budget100控制每個軌跡保留多少個歷史外觀特征超過上限會淘汰最早的這個參數(shù)在長時間跟蹤里影響很大——目標(biāo)走了十分鐘后回頭如果特征池太小外觀信息已經(jīng)被擠出去了。embeddermobilenet是輕量級特征提取器速度和精度兼顧如果追求重識別準(zhǔn)確率可以換osnet但推理耗時幾乎翻倍。2.3 GaitSet 步態(tài)識別層跨鏡頭匹配的身份特征來源DeepSORT 只解決單鏡頭內(nèi)的 ID 維持跨鏡頭后外觀特征已經(jīng)不可靠這時候就要靠步態(tài)特征兜底。GaitSet 的做法是把一個行走序列的多幀輪廓圖拆成若干組對每組做集合池化再用水平金字塔映射生成步態(tài)特征。這套源碼里步態(tài)部分的關(guān)鍵是它的數(shù)據(jù)組織方式每個行人的一組輪廓圖放在同一個目錄下序列長度和信息幀的選擇直接影響特征質(zhì)量。在工程對接上GaitSet 的輸出是一個 256 維或 512 維的特征向量這個向量和 DeepSORT 的外觀特征向量不在同一個空間里不能直接拼在一起做距離度量。常見的做法是分段判斷目標(biāo)在鏡頭 A 丟失前緩存它最后 20 幀的步態(tài)特征目標(biāo)在鏡頭 B 出現(xiàn)后先讓 DeepSORT 跑上 10 幀穩(wěn)定軌跡再提取這段軌跡的步態(tài)特征與鏡頭 A 的緩存特征做余弦相似度。相似度超過閾值就判定為同一個 ID然后把這個新的軌跡 ID 映射回原來的全局 ID。這一步在源碼里對應(yīng)的是 GNN 模塊介入之前的預(yù)處理。3. GNN跨鏡頭關(guān)聯(lián)源碼走讀從 build_adjacency_matrix 到 gnn_propagate3.1 文件清單理清這套源碼跨鏡頭部分到底做了什么打開壓縮包頂層文件里值得先看的是以下幾類gnn_propagate.cpp、build_adjacency_matrix.cpp以及對應(yīng)的.cu文件它們是跨鏡頭關(guān)聯(lián)的核心setup.cfg、isort.cfg、.flake8只是工程配置Dockerfile解決的是環(huán)境一致性。市面上大多數(shù) yolov5DeepSORT 項目到單鏡頭跟蹤就結(jié)束了這里的gnn_propagate和build_adjacency_matrix才是真正的畢設(shè)亮點。build_adjacency_matrix負(fù)責(zé)構(gòu)建圖結(jié)構(gòu)。圖的節(jié)點是來自不同鏡頭的軌跡片段邊的權(quán)重是軌跡之間的相似度。相似度由兩部分組成外觀特征的余弦相似度和時間空間上的共現(xiàn)約束。gnn_propagate在這個圖上做特征的迭代傳播讓每個軌跡節(jié)點的特征吸收鄰居節(jié)點的信息從而緩解單鏡頭內(nèi)外觀特征不夠判別性的問題。這個思路來源于圖神經(jīng)網(wǎng)絡(luò)在行人重識別上的應(yīng)用畢設(shè)答辯時是一個很好的切入點。3.2 build_adjacency_matrix 的源碼邏輯與參數(shù)build_adjacency_matrix.cpp做的事情可以拆解成三步讀取所有軌跡片段的外觀特征和時空信息計算兩兩之間的初始相似度再根據(jù)閾值和時空約束生成稀疏鄰接矩陣。核心是相似度計算時兩個損失的權(quán)衡——外觀距離用余弦距離時空距離用起始幀和結(jié)束幀的重疊程度。下面的代碼展示了 CPU 版本的骨架邏輯。// build_adjacency_matrix.cpp 骨架邏輯 #include vector #include cmath struct Tracklet { int camera_id; int start_frame; int end_frame; std::vectorfloat feature; // 外觀特征GaitSet 或 ReID 提取 }; // 構(gòu)建鄰接矩陣 std::vectorstd::vectorfloat BuildAdjacencyMatrix( const std::vectorTracklet tracklets, float sim_threshold, float time_window) { int n tracklets.size(); std::vectorstd::vectorfloat adj(n, std::vectorfloat(n, 0.0f)); for (int i 0; i n; i) { for (int j i 1; j n; j) { // 同鏡頭不連接跨鏡頭關(guān)聯(lián)不需要同鏡頭內(nèi)的邊 if (tracklets[i].camera_id tracklets[j].camera_id) continue; // 時間窗口約束兩個軌跡在時間上必須有重疊或接近 int overlap std::min(tracklets[i].end_frame, tracklets[j].end_frame) - std::max(tracklets[i].start_frame, tracklets[j].start_frame); if (overlap time_window) continue; // 外觀特征余弦相似度 float cos_sim CosineSimilarity(tracklets[i].feature, tracklets[j].feature); if (cos_sim sim_threshold) { adj[i][j] cos_sim; adj[j][i] cos_sim; } } } return adj; }sim_threshold的取值決定了圖的稀疏程度。值設(shè)太高很多真實匹配的邊被切斷GNN 傳播時信息傳不到值設(shè)太低圖變成稠密圖計算量和噪聲同時上升。一般取 0.7 到 0.75 之間的值具體要看步態(tài)特征的質(zhì)量——如果 GaitSet 用的是 CASIA-B 數(shù)據(jù)集預(yù)訓(xùn)練權(quán)重特征判別性尚可這個閾值可以稍微放開到 0.65。time_window這個參數(shù)很容易被誤解成時間差閾值實際上它是時間重疊度單位是幀跨鏡頭場景中兩個鏡頭的時間可能不同步所以錄入時最好先做時間對齊。3.3 gnn_propagate 的傳播過程與 CUDA 加速gnn_propagate在鄰接矩陣上運行多個圖卷積層每一層把鄰居節(jié)點的特征加權(quán)融合到當(dāng)前節(jié)點。整個傳播過程涉及矩陣乘法數(shù)據(jù)量大時用 CPU 會非常慢所以源碼里額外提供了_kernel.cu的 CUDA 實現(xiàn)。GPU 版本的核心是在 kernel 函數(shù)里并行遍歷每一個節(jié)點聚合鄰居特征。// gnn_propagate_kernel.cu 核函數(shù)偽代碼 __global__ void PropagateKernel( const float* features, // [N, D] 特征矩陣 const float* adj, // [N, N] 鄰接矩陣 float* out_features, // [N, D] 輸出特征 int N, int D) { int row blockIdx.x * blockDim.x threadIdx.x; if (row N) return; for (int d 0; d D; d) { float sum 0.0f; for (int col 0; col N; col) { sum adj[row * N col] * features[col * D d]; } out_features[row * D d] sum; } }這段 kernel 代碼的瓶頸在內(nèi)存訪問上。adj是稀疏矩陣但 kernel 里仍然用稠密方式遍歷N 是軌跡數(shù)量一般不會超過幾千所以問題不大如果軌跡上萬條N 的平方就不只是計算量的問題內(nèi)存也會爆掉——一個 10000 x 10000 的 float 矩陣就是 400MB兩個就是 800MB。畢設(shè)規(guī)模下不需要優(yōu)化存儲格式但如果你要拿去跑更大的數(shù)據(jù)集建議把adj改造成 CSR 格式再喂給 kernel。圖中的注釋也說明了傳播的一層只做了線性聚合沒有加非線性激活函數(shù)這是刻意為之的——多跨鏡頭關(guān)聯(lián)的特征空間不需要引入太多非線性保持線性傳播的穩(wěn)定性比擬合能力更重要。3.4 編譯與調(diào)用setup.cfg 和 .flake8 在這里的作用這些 .cu 文件不能直接用 Python 調(diào)用需要先編譯成 PyTorch 的 C 擴展。常見做法是寫一個setup.py文件把gnn_propagate.cpp和gnn_propagate_kernel.cu通過torch.utils.cpp_extension.CUDAExtension編譯。源碼包里沒列出setup.py但列出的setup.cfg和.flake8說明項目原本是用 setuptools 管理的可以依此補全編譯入口。# 編譯自定義 CUDA 算子的常用命令 python setup.py build_ext --inplace編譯前先確認(rèn)三件事CUDA 版本和 PyTorch 版本匹配、GPU 算力在 6.0 以上GTX 10 系及以上、TORCH_CUDA_ARCH_LIST環(huán)境變量設(shè)置了正確的算力。我見過不少人在這一步卡住報錯大多是undefined symbol或者gcc: error: unrecognized command line option前者是編譯順序錯了后者是 GCC 版本太新需要降級到 7 以下。setup.cfg里的[metadata]和[options]字段定義了包的版本、依賴和入口如果編譯不通過先檢查它和setup.py里的參數(shù)是否一致。4. 環(huán)境部署與訓(xùn)練自己的數(shù)據(jù)集Dockerfile 和參數(shù)配置4.1 Dockerfile 里的環(huán)境是怎么搭起來的這套源碼自帶 Dockerfile這是最省心的一環(huán)。讀 Dockerfile 的目的是搞明白項目依賴了哪些系統(tǒng)庫和 Python 包避免在本地環(huán)境里缺這個缺那個。典型的多階段構(gòu)建流程是第一步拉一個 CUDA 官方鏡像比如nvidia/cuda:11.3.1-cudnn8-devel-ubuntu20.04第二步安裝 Python 3.8 和系統(tǒng)依賴第三步用 pip 裝 PyTorch、torchvision 和項目所需的包最后把源碼目錄復(fù)制進容器。# 構(gòu)建并進入容器 docker build -t gait_track:latest . docker run -it --gpus all --shm-size8g gait_track:latest /bin/bash--shm-size8g是容易忽略的參數(shù)。PyTorch 的 DataLoader 在多進程模式下會用共享內(nèi)存做數(shù)據(jù)緩存容器默認(rèn)的/dev/shm只有 64MB數(shù)據(jù)加載稍微大一點就報Bus error或out of shared memory這個坑在監(jiān)控視頻數(shù)據(jù)集上特別常見。如果宿主機的內(nèi)存有限也可以把num_workers設(shè)為 0但那樣訓(xùn)練速度會明顯下降。4.2 數(shù)據(jù)集組織方式y(tǒng)olov5、DeepSORT、GaitSet 三種數(shù)據(jù)格式整套系統(tǒng)用到三種不同的數(shù)據(jù)標(biāo)注格式這是最容易混亂的地方需要分別準(zhǔn)備。模塊數(shù)據(jù)格式目錄組織關(guān)鍵點yolov5YOLO txt 格式每行cls x y w himages/和labels/按 train/val 分坐標(biāo)是歸一化后的不是像素值DeepSORT檢測結(jié)果文本每行frame_id, track_id, x, y, w, h, confMOT 風(fēng)格訓(xùn)練時不需要標(biāo)注需要的是檢測框和 IDGaitSet輪廓圖序列每段序列一個文件夾subject/sequence/按行人 ID 分需要先對原圖做前景分割提取二值輪廓GaitSet 的數(shù)據(jù)準(zhǔn)備是最耗時的。你需要一個分割模型把行人從背景中摳出來或者用背景減除算法生成輪廓圖。輪廓圖的尺寸建議統(tǒng)一為 64x44這是 GaitSet 訓(xùn)練時的常用尺寸。輪廓的質(zhì)量直接決定步態(tài)特征的好壞——輪廓上有空洞、有背景殘留特征就會帶噪聲。如果你手頭的是 CASIA-B 數(shù)據(jù)集可以直接用官方的預(yù)處理腳本如果是自采數(shù)據(jù)需要先過一遍檢測和分割再生成訓(xùn)練集。4.3 超參數(shù)調(diào)整yolov5 訓(xùn)練自己的數(shù)據(jù)集yolov5 訓(xùn)練時的超參數(shù)文件是data/hyps/hyp.scratch-low.yaml里面包含了 lr、mosaic、mixup 等參數(shù)。用預(yù)訓(xùn)練權(quán)重做遷移學(xué)習(xí)時建議把lr0從默認(rèn)的 0.01 降到 0.001因為你的數(shù)據(jù)集規(guī)模和類別數(shù)都變了過大的學(xué)習(xí)率會破壞預(yù)訓(xùn)練特征。# hyp.scratch-low.yaml 關(guān)鍵參數(shù)訓(xùn)練行人檢測時推薦修改 lr0: 0.001 # 初始學(xué)習(xí)率遷移學(xué)習(xí)用小一點 lrf: 0.01 # 最終學(xué)習(xí)率比例 warmup_epochs: 3.0 # 預(yù)熱輪數(shù) mosaic: 1.0 # mosaic 增強行人數(shù)據(jù)集可以保留 fliplr: 0.5 # 水平翻轉(zhuǎn)對行人檢測友好# 訓(xùn)練命令 python train.py --data person.yaml --weights yolov5s.pt --img 640 --batch 16 --epochs 100 --device 0--batch 16是在 8GB 顯存的 GPU 上比較穩(wěn)妥的值顯存不夠就減半。--img 640和推理時的size640保持一致訓(xùn)練和推理尺寸不一致會掉點。5. 避坑指南跨鏡頭關(guān)聯(lián)里最容易翻車的五個點5.1 權(quán)重文件和 .cu 文件不匹配編譯過了但結(jié)果全錯現(xiàn)象編譯成功import成功但 GNN 傳播的輸出全是 NaN。原因gnn_propagate_kernel.cu是在一個特定 PyTorch 版本下寫的和你當(dāng)前的 PyTorch 版本在張量內(nèi)存布局或計算圖處理上不兼容。解決先跑一個 2 節(jié)點 3 特征的小用例用 CPU 實現(xiàn)的結(jié)果和 GPU 實現(xiàn)的結(jié)果做逐位對比如果差異巨大降級 PyTorch 版本或者檢查是否有重復(fù)定義的宏。5.2 時間對齊沒做跨鏡頭匹配率直接砍半現(xiàn)象鏡頭 A 和鏡頭 B 記錄的是同一場景但時間戳相差了十幾秒GNN 的鄰接矩陣?yán)飵缀跽也坏娇缭絻蓚€相機的邊。原因time_window參數(shù)判斷的是軌跡的時間重疊度兩個鏡頭的時間不同步導(dǎo)致同一個目標(biāo)在 A 鏡頭結(jié)束和 B 鏡頭開始的幀號完全對不上。解決在預(yù)處理階段做一個時間偏移校準(zhǔn)用同一時刻出現(xiàn)在兩個畫面中的目標(biāo)哪怕是不同 ID估算幀號偏差然后統(tǒng)一到同一個時間軸再跑關(guān)聯(lián)流程。5.3 GaitSet 特征沒有歸一化相似度計算失靈現(xiàn)象余弦相似度算出來全部在 0.99 以上設(shè)什么閾值都沒區(qū)分度。原因GaitSet 輸出的特征向量模長差異巨大直接算余弦相似度等于在比模長而不是比方向。解決在特征輸入 GNN 之前強制做 L2 歸一化讓每個特征向量的模長為 1。import torch import torch.nn.functional as F def normalize_features(features): # features: [N, D] 原始步態(tài)特征 return F.normalize(features, p2, dim1)5.4 小目標(biāo)檢測漏檢軌跡斷裂后跟蹤器無法恢復(fù)現(xiàn)象監(jiān)控畫面里的人離攝像頭遠(yuǎn)身體只有 20 像素高yolov5 經(jīng)常漏檢DeepSORT 的軌跡頻繁丟失。原因yolov5 在 COCO 上的小目標(biāo) AP 本來就低20 像素的行人已經(jīng)超出了常規(guī)檢測能力。解決一是把推理尺寸從 640 提高到 1280顯存夠的話二是對漏檢幀做插值。最穩(wěn)妥的做法還是換一個針對小目標(biāo)優(yōu)化的檢測器分支或者給檢測結(jié)果加一幀的容忍——上一幀有檢測框、這一幀沒有先沿用上一幀位置做卡爾曼預(yù)測。5.5 圖里節(jié)點全是負(fù)樣本步態(tài)識別退化成換裝重識別現(xiàn)象跨鏡頭的匹配準(zhǔn)確率還不如純 ReID加了 GNN 反而掉點。原因負(fù)樣本不同 ID 的軌跡在圖里占了大多數(shù)GNN 傳播時每個節(jié)點的特征都被大量負(fù)樣本的噪聲特征污染了正樣本的信息被稀釋。解決在build_adjacency_matrix里提高相似度閾值或者在構(gòu)建圖的時候加上一個先驗約束——只有空間上可能銜接的鏡頭對才允許連邊。最直接的辦法是統(tǒng)計每個目標(biāo)的軌跡平均長度去掉那些小于 10 幀的短軌跡——步態(tài)特征本身就需要連續(xù)幀才能穩(wěn)定提取。6. 跨鏡頭軌跡拼接的落盤驗證用兩個攝像頭實測一輪拿到源碼后第一步不是改代碼而是先驗證跨鏡頭拼接的質(zhì)量。我習(xí)慣的做法是準(zhǔn)備兩個模擬鏡頭的數(shù)據(jù)同一個場景里兩個不同角度的錄屏中間有 3 秒的交接區(qū)。跑完整個流程后看兩件事。第一件事是檢查軌跡文件里有沒有出現(xiàn)「同一個全局 ID 在不同鏡頭里時間上重疊」的情況。如果 ID 5 在鏡頭 A 的第 100 幀和鏡頭 B 的第 100 幀同時出現(xiàn)說明 GNN 匹配錯了。第二件事是可視化拼接結(jié)果把兩個鏡頭的畫面拼在一起用同一個顏色畫同一個人這個辦法笨但直接比任何指標(biāo)都直觀。import cv2 import numpy as np # 讀取兩個攝像頭的軌跡輸出 # 格式: frame_id, global_id, camera_id, x, y, w, h cam_a np.loadtxt(track_cam_a.txt, delimiter,) cam_b np.loadtxt(track_cam_b.txt, delimiter,) # 按全局 ID 把軌跡拆開檢測重疊 ids_a set(cam_a[:, 1]) ids_b set(cam_b[:, 1]) overlap_errors [] for gid in ids_a ids_b: frames_a set(cam_a[cam_a[:, 1] gid][:, 0]) frames_b set(cam_b[cam_b[:, 1] gid][:, 0]) if frames_a frames_b: overlap_errors.append(gid) print(f重疊軌跡數(shù): {len(overlap_errors)})overlap_errors如果超過 3%說明鄰接矩陣的邊建得太寬松了優(yōu)先調(diào)整sim_threshold和時間窗口。如果這個指標(biāo)沒問題再計算匹配的準(zhǔn)確率——手動標(biāo)注一段視頻里真實匹配的軌跡對數(shù)和 GNN 輸出的匹配結(jié)果做對比。做完這兩步你才真正摸透了這套源碼的輸出和評價維度后面無論改哪個模塊都有基準(zhǔn)可對照。這套流程里的每一步我都吃過虧。以前偷懶直接拿默認(rèn)配置跑結(jié)果跨鏡頭 ID 切換率慘不忍睹檢查了半天才發(fā)現(xiàn)是時間偏移和數(shù)據(jù)歸一化的問題。那之后每次做跨鏡頭實驗我都強制先驗證數(shù)據(jù)對齊再跑完整鏈路。希望這篇筆記能幫你少走幾步彎路。本文還有配套的精品資源點擊獲取