密集人群計數(shù):原理、PyTorch代碼與訓(xùn)練避坑)
簡介基于多列卷積神經(jīng)網(wǎng)絡(luò)MCNN的密集人群計數(shù)課程設(shè)計源碼包面向機器學(xué)習(xí)與人工智能專業(yè)的在校學(xué)生、課程設(shè)計及畢設(shè)開發(fā)者解決監(jiān)控場景下的人群密度估計與人數(shù)統(tǒng)計問題。壓縮包內(nèi)含完整Python工程覆蓋數(shù)據(jù)預(yù)處理、模型定義、訓(xùn)練與測試流程并配套ShanhaiTech和UCSD兩套實驗數(shù)據(jù)集、已訓(xùn)練模型權(quán)重及說明文檔。包內(nèi)共2000個文件以npy數(shù)據(jù)文件為主另有9個py腳本、5個xml配置和1個md說明分別承擔(dān)數(shù)據(jù)加載、網(wǎng)絡(luò)構(gòu)建、參數(shù)配置與使用指引等作用整體大小約123MB目錄結(jié)構(gòu)清晰便于按模塊查閱。項目代碼均測試運行成功可作為課程大作業(yè)、畢業(yè)設(shè)計或入門進(jìn)階的完整參考。當(dāng)前已有96人學(xué)習(xí)下載對需要復(fù)現(xiàn)人群計數(shù)任務(wù)、理解多列卷積設(shè)計思路的讀者而言是一份能直接運行的實操性資源。1. 機器學(xué)習(xí)大作業(yè)選密集人群計數(shù)多列卷積模型憑什么能拿到高分課程設(shè)計最怕的不是題難而是題目看著嚇人、實際上沒東西可以寫?;诙嗔芯矸e的密集人群計數(shù)是這類大作業(yè)里性價比比較高的一個選項數(shù)據(jù)集公開、評價指標(biāo)清楚MAE/MSE、可視化效果直觀而且核心網(wǎng)絡(luò) MCNN 用 PyTorch 手寫不到一百行不需要加載任何預(yù)訓(xùn)練權(quán)重。它解決的是在擁擠場景下估計單張圖像里總?cè)藬?shù)的問題適合作為機器學(xué)習(xí)課程設(shè)計、深度學(xué)習(xí)入門項目也能拿來做人群密度分析的工程預(yù)研。別期待它達(dá)到商用精度但作為一篇能講清原理、能現(xiàn)場跑通的高分課程設(shè)計方向完全成立。我見過太多組選目標(biāo)檢測被 GPU 顯存卡死而密度回歸這條路CPU 都能跑完小規(guī)模實驗。2. 多列卷積與密度圖MCNN 的三列感受野為什么能扛住密集遮擋2.1 人群計數(shù)不是數(shù)人頭而是回歸一張密度圖第一次接觸人群計數(shù)的人直覺一定是“把每個人都框出來”。這個思路在稀疏場景下沒問題但密集人群里人頭互相遮擋、尺度差異極大檢測框的 NMS 合并一次就能把幾十個人吃掉?;诙嗔芯矸e的密集人群計數(shù)走的是另一條路不數(shù)框而是讓網(wǎng)絡(luò)回歸一張密度圖。每個標(biāo)注點(x, y)被展開成一個高斯峰整張標(biāo)注圖變成連續(xù)的“人頭發(fā)光圖”網(wǎng)絡(luò)輸出的每個像素值表示該位置的人頭密集程度對全圖求和就是人數(shù)。這套思路最早來自 MCNN 那篇經(jīng)典工作CVPR 2016配套的 ShanghaiTech 數(shù)據(jù)集也成了后續(xù)所有計數(shù)模型的基準(zhǔn)。課程設(shè)計選它有個天然優(yōu)勢損失函數(shù)就是一個像素級 MSE評價指標(biāo)只有 MAE 和 MSE 兩個數(shù)答辯時十分鐘能把原理講完剩下的時間都留給現(xiàn)場跑 demo。相比之下檢測類方法要解釋 anchor、NMS、正負(fù)樣本均衡三句話就繞暈。密度回歸把問題從“分類定位”簡化成了“連續(xù)值回歸”模型更容易收斂也更容易讓新手理解梯度回傳在做什么。這里我一般會強調(diào)一個容易被忽略的點密度圖方法并不要求網(wǎng)絡(luò)做“逐個人頭匹配”所以它對遮擋天然魯棒。只要密度分布在空間上大致正確求和之后的人數(shù)誤差就能控制在可接受范圍。這也是為什么密密麻麻幾百人的街景圖密度回歸法能跑出比檢測法穩(wěn)定得多的計數(shù)結(jié)果。用課程設(shè)計的尺度去衡量這種“魯棒性”就是拿高分的關(guān)鍵敘事。2.2 MCNN 的三列并行結(jié)構(gòu)大中小感受野如何分工既然人群里的人頭大小差異巨大一個固定卷積核的 CNN 就很難照顧所有尺度。MCNN 的解法是“打群架”三個獨立的卷積子網(wǎng)絡(luò)并行每列用不同大小的卷積核最后把三列特征拼到一起再回歸密度圖。常見實現(xiàn)里第一列用 9×7 的卷積核感受野最大適合捕捉近處的大人頭和身體輪廓第二列用 7×5覆蓋中等尺度第三列用 5×3只盯局部紋理對應(yīng)遠(yuǎn)處的小人頭。三列的結(jié)構(gòu)有點像“三個不同視力的人同時看圖”一個看全貌一個看中景一個看細(xì)節(jié)最后把三個判斷拼起來。相比單列網(wǎng)絡(luò)多列的好處是特征互補梯度在并行分支里各自回傳不容易同時陷入局部最優(yōu)。訓(xùn)練時三列不需要任何額外監(jiān)督只有最終融合輸出參與損失計算這讓代碼實現(xiàn)變得非常簡單。你甚至可以把其中兩列理解成某種“數(shù)據(jù)增強”——讓同一張圖以三種感受野同時過網(wǎng)絡(luò)等于隱式做了多尺度訓(xùn)練。需要注意卷積層的 padding 設(shè)置。MCNN 的原始設(shè)計里各列卷積后要拼接所以在 conv 層必須要保持特征圖尺寸不變常見做法是每個卷積層都手動設(shè)置 padding(kernel_h//2, kernel_w//2)池化層保持尺寸不變或只減半一次。如果哪一列的尺寸縮了后面 concat 就會直接報 shape 不匹配這是新手最容易犯的錯誤。2.3 損失函數(shù)與評價指標(biāo)MAE 和 MSE 到底在衡量什么訓(xùn)練階段用的是歐氏距離損失也就是預(yù)測密度圖與真值密度圖逐像素差的平方和。它假設(shè)每個像素的誤差是獨立同分布的把計數(shù)問題完全當(dāng)作一個回歸任務(wù)。選擇這個損失的原因很簡單標(biāo)注只有點坐標(biāo)展開成高斯核之后真值本身就是平滑的連續(xù)函數(shù)L2 損失能讓網(wǎng)絡(luò)學(xué)到“糊一點沒關(guān)系位置要對”的傾向。評估時只看兩個數(shù)字MAE 是預(yù)測人數(shù)與真實人數(shù)差值的絕對平均反映平均偏差MSE 是差值的平方平均再開方放大了那些“某張圖數(shù)錯幾十人”的極端樣本。課程設(shè)計里我建議把這兩個指標(biāo)都打印出來并且在文檔里寫一句“MAE 低代表整體穩(wěn)定MSE 低代表沒有災(zāi)難性誤差。”這一句話就能讓評分老師覺得你真的理解了指標(biāo)含義而不是只會跑腳本。另外要提醒一點訓(xùn)練損失和評估指標(biāo)并不完全一致。訓(xùn)練用的是逐像素 L2評估用的是全局人數(shù) L1/L2所以可能出現(xiàn)訓(xùn)練損失下降但 MAE 不降的情況這在第 5 章會專門講到。理解這個差異能幫你少走很多彎路。3. 數(shù)據(jù)與代碼落地SHHB 密度圖生成腳本和 MCNN 的 PyTorch 最小實現(xiàn)3.1 數(shù)據(jù)集選型ShanghaiTech 還是 UCF-QNRF課程設(shè)計最常見的選擇是 ShanghaiTech 數(shù)據(jù)集尤其推薦其中的 Part_BSHHB也就是街道密集場景部分。Part_A 是開闊廣場人群尺度變化更極端Part_B 是沿街人行道人物相對均勻訓(xùn)練更容易收斂。UCF-QNRF 更大、圖像分辨率更高、難度明顯上一個臺階適合用來做“我還能做得更好”的進(jìn)階實驗但作為大作業(yè)主體方案容易讓訓(xùn)練時間失控。在使用 ShanghaiTech 時我建議先把原數(shù)據(jù)集切出一個 30~50 張的 mini 子集單獨跑通“讀圖 → 生成密度圖 → 訓(xùn)練幾輪 → 保存模型 → 推理求和”的全流程再上全量數(shù)據(jù)。這一步能幫你提前暴露所有預(yù)處理 bug而不是在正式訓(xùn)練兩小時后才發(fā)現(xiàn)密度圖是黑的。標(biāo)注文件一般是 .mat 格式里面存儲的 points 是一個二維數(shù)組第一列是 x 坐標(biāo)、第二列是 y 坐標(biāo)。讀取時可以用 scipy.io.loadmat注意 mat 文件里可能包了一層結(jié)構(gòu)需要 points[0] 或直接取 key 來拿到真正的坐標(biāo)矩陣。3.2 密度圖生成腳本幾何自適應(yīng)高斯核的三個關(guān)鍵參數(shù)密度圖是通行做法里決定訓(xùn)練上限的一步。論文里用的是幾何自適應(yīng)高斯核對每個標(biāo)注點 xi計算它到最近的 k 個人頭中心的平均距離然后令 sigma spread * mean_distancespread 通常取 0.3k 取 3 或 4。距離越近說明人群越密集高斯峰就越窄距離遠(yuǎn)則峰更寬。這個邏輯對應(yīng)到物理意義上就是“人群擠在一起時每個頭的可視范圍更小”。下面是一段可以直接用的密度圖生成代碼依賴 numpy 和 scipyimport numpy as np from scipy.ndimage import gaussian_filter from scipy.spatial import KDTree def generate_density_map(img_shape, points, k3, spread0.3): 根據(jù)標(biāo)注點生成幾何自適應(yīng)密度圖 img_shape: (H, W) points: N x 2 的數(shù)組每行是 (x, y) density np.zeros(img_shape, dtypenp.float32) if len(points) 0: return density # 用 KDTree 快速找每個點到其他點的距離 tree KDTree(points) # 對每個標(biāo)注點計算到最近 k 個鄰居的平均距離 for i, (x, y) in enumerate(points): # 轉(zhuǎn)換成整數(shù)坐標(biāo)防止越界 xi, yi int(round(x)), int(round(y)) if xi img_shape[1] or yi img_shape[0]: continue # 查詢 k1 個鄰居第一個是自身所以取 [1:] dists, _ tree.query(points[i], kk1) dists dists[1:] # 平均距離不能為 0合力避免除零 avg_dist np.mean(dists) if len(dists) 0 else 1.0 if avg_dist 1e-6: avg_dist 1.0 sigma spread * avg_dist density[yi, xi] 1.0 if sigma 0: density gaussian_filter(density, sigmasigma) return density這段代碼是逐點累加再統(tǒng)一高斯濾波的近似寫法好處是代碼短、不容易在邊界出錯。需要注意的點有三個一是 KDTree 查詢時要把自身排除否則平均距離會被一個 0 拉低二是坐標(biāo)順序是 (x, y)而行列索引是 [y, x]順序搞反是密度圖全黑或錯位的最常見原因三是 sigma 太小時高斯濾波幾乎不起作用密度圖會退化成一堆孤立峰值這時網(wǎng)絡(luò)學(xué)到的就不是密度而是“找點”后面測試計數(shù)會偏大。對課程設(shè)計來說如果不想做逐點累加也可以對每個點單獨生成一塊高斯 patch 加到全零圖上但要注意邊緣截斷否則邊緣人頭會被“切”成半塊導(dǎo)致計數(shù)系統(tǒng)性偏低。3.3 MCNN 網(wǎng)絡(luò)定義與訓(xùn)練主循環(huán)最小可復(fù)現(xiàn)版本拿到密度圖后網(wǎng)絡(luò)定義就簡單了。MCNN 的三列子網(wǎng)絡(luò)都是“卷積 ReLU 最大池化”的堆疊最后拼接并輸出單通道密度圖。我一般會把三列的輸出通道統(tǒng)一到 24融合后再接兩個卷積層。整個模型參數(shù)量在百萬級比 ResNet 小一個數(shù)量級CPU 上也能慢慢訓(xùn)。import torch import torch.nn as nn import torch.nn.functional as F class MCNN(nn.Module): def __init__(self): super().__init__() # 第一列大感受野核尺寸偏大 self.branch1 nn.Sequential( nn.Conv2d(3, 16, kernel_size(9, 7), padding(4, 3)), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(16, 24, kernel_size(7, 5), padding(3, 2)), nn.ReLU(), ) # 第二列中等感受野 self.branch2 nn.Sequential( nn.Conv2d(3, 20, kernel_size(7, 5), padding(3, 2)), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(20, 24, kernel_size(5, 3), padding(2, 1)), nn.ReLU(), ) # 第三列小感受野盯細(xì)節(jié) self.branch3 nn.Sequential( nn.Conv2d(3, 24, kernel_size(5, 3), padding(2, 1)), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(24, 24, kernel_size(3, 1), padding(1, 0)), nn.ReLU(), ) # 融合后回歸密度圖 self.fuse nn.Sequential( nn.Conv2d(72, 48, kernel_size3, padding1), nn.ReLU(), nn.Conv2d(48, 1, kernel_size1), ) def forward(self, x): f1 self.branch1(x) f2 self.branch2(x) f3 self.branch3(x) # 三列特征通道拼接 fused torch.cat([f1, f2, f3], dim1) density self.fuse(fused) return density這個網(wǎng)絡(luò)里三個分支都只做了一次池化輸入如果從 768×1024 進(jìn)來融合后的特征圖大概是 384×512計算量適中。要注意所有卷積層都按核尺寸配了 padding保證三列輸出在空間維度上對齊否則 torch.cat 會直接報錯。訓(xùn)練主循環(huán)和普通回歸任務(wù)沒有區(qū)別用 MSE Loss Adam 即可from torch.utils.data import Dataset, DataLoader class CrowdDataset(Dataset): def __init__(self, image_paths, density_paths): self.image_paths image_paths self.density_paths density_paths def __len__(self): return len(self.image_paths) def __getitem__(self, idx): import cv2 img cv2.imread(self.image_paths[idx]) img cv2.cvtColor(img, cv2.COLOR_BGR2RGB) img cv2.resize(img, (512, 384)) img img.astype(np.float32) / 255.0 img img.transpose(2, 0, 1) density np.load(self.density_paths[idx]) density cv2.resize(density, (512, 384)) density density.astype(np.float32) return torch.from_numpy(img.copy()), torch.from_numpy(density.copy()).unsqueeze(0) model MCNN() optimizer torch.optim.Adam(model.parameters(), lr1e-4) criterion nn.MSELoss() loader DataLoader(train_dataset, batch_size1, shuffleTrue) for epoch in range(50): for imgs, dens in loader: pred model(imgs) loss criterion(pred, dens) optimizer.zero_grad() loss.backward() optimizer.step()注意代碼里把圖像縮放到 384×512 再訓(xùn)練密度圖要同步 resize否則人數(shù)求和會差一個縮放比例平方。Dataset 里每次讀取都做 resize省去了預(yù)先存儲的麻煩代價是訓(xùn)練時會頻繁觸發(fā) CPU 數(shù)據(jù)讀取建議把 num_workers 調(diào)到 4 以上。這個最小版本可以在普通筆記本 CPU 上 20 分鐘跑完 50 輪小規(guī)模實驗適合先驗證流程再上 GPU。4. 訓(xùn)練參數(shù)與模型落地學(xué)習(xí)率、batch size 和模型自測的一次定檔4.1 必調(diào)參數(shù)表從這些值開始別一上來就調(diào)網(wǎng)絡(luò)結(jié)構(gòu)很多大作業(yè)翻車不是模型寫錯而是參數(shù)一開始就定得離譜。MCNN 這種全卷積回歸網(wǎng)絡(luò)對學(xué)習(xí)率極其敏感學(xué)習(xí)率一大密度圖直接變成全零或全 NaN學(xué)習(xí)率太小50 輪下去 MAE 還在原地。下面是一組我常用的課程設(shè)計基線參數(shù)可以直接作為起點。參數(shù)推薦值調(diào)整方向?qū)W習(xí)率Adam 1e-4 起步損失震蕩就降一半連續(xù) 5 輪不降就 /2batch size1~2圖像較大顯存不夠就固定 batch1訓(xùn)練輪數(shù)50~100看驗證 MAE 曲線早停比死磕更有效輸入尺寸最長邊 800 或固定 384×512越小越快但密度峰會變窄高斯核 k3~4人群越密集取越小spread0.3密度圖太平就降到 0.25訓(xùn)練不穩(wěn)就升到 0.4優(yōu)化器Adam 或 SGD(momentum0.9)課程設(shè)計用 Adam 省心隨機種子固定 42保證答辯時結(jié)果可復(fù)現(xiàn)補充一個血淚經(jīng)驗不要一開始上 batch size 大孝。密度圖回歸的 target 是一張平滑圖batch 里每張圖的密度峰值位置差異極大batch 一大反而讓梯度方向互相抵消。MCNN 原文很多實驗都是 batch1我自己的實踐也證明 batch1 在小數(shù)據(jù)集上更穩(wěn)損失曲線更平滑。如果你發(fā)現(xiàn) batch1 時損失抖動特別厲害優(yōu)先檢查輸入是否歸一化到了 0~1而不是去調(diào)學(xué)習(xí)率。還有一種常見誤用是拿 ImageNet 預(yù)訓(xùn)練權(quán)重來初始化 MCNN 分支。MCNN 的三列輸入是原始像素不需要任何預(yù)訓(xùn)練強行加載 ResNet encoder 反而會因為通道數(shù)和卷積核尺寸不匹配造成各種維度錯誤。課程設(shè)計文檔里寫“網(wǎng)絡(luò)從零訓(xùn)練不依賴外部預(yù)訓(xùn)練模型”是加分的說明你真的理解了網(wǎng)絡(luò)設(shè)計邏輯。4.2 模型保存與推理腳本先自測再交作業(yè)后悔藥要提前備好訓(xùn)練完成后保存模型不只是為了讓老師能跑更是為了給自己留后悔藥。我一般每 10 輪保存一個 checkpoint只存 state_dict 和當(dāng)前 MAE不存整個 model 對象這樣文件小、加載快。推理腳本里有一個關(guān)鍵細(xì)節(jié)預(yù)測密度圖求和得到人數(shù)之前一定要把“訓(xùn)練時做的 resize 縮放”還原回去。import torch import cv2 import numpy as np def predict_count(model, image_path, devicecpu): model.eval() img cv2.imread(image_path) img cv2.cvtColor(img, cv2.COLOR_BGR2RGB) orig_h, orig_w img.shape[:2] # 推理尺寸需要和訓(xùn)練時保持一致 test_h, test_w 384, 512 img_resized cv2.resize(img, (test_w, test_h)) img_tensor torch.from_numpy(img_resized.transpose(2, 0, 1)) img_tensor img_tensor.float().unsqueeze(0) / 255.0 with torch.no_grad(): density model(img_tensor).squeeze().numpy() # 密集人群計數(shù)關(guān)鍵步驟密度圖求和 縮放還原 count_resized density.sum() scale_factor (orig_h / test_h) * (orig_w / test_w) count_original count_resized * scale_factor return count_original, density這段代碼里count_resized 是在 384×512 尺度下的人數(shù)但原圖尺寸和測試尺寸不一致直接把密度圖每個像素當(dāng)成真實面積會導(dǎo)致計數(shù)偏大??s放還原系數(shù)是面積比也就是高之比乘以寬之比。我見過有人把 resize 后的人數(shù)直接當(dāng)最終答案交給老師結(jié)果原圖 800 人預(yù)測輸出 1200答辯現(xiàn)場翻車。所以我把這個還原邏輯寫成一個函數(shù)每次推理自動完成避免手工換算。加載模型時還有一個容易踩的坑如果保存時用的是torch.save(model.state_dict(), path)加載就必須先實例化相同結(jié)構(gòu)再load_state_dict。直接torch.load(path)再拿去預(yù)測大概率報AttributeError。把這些代碼寫成一個infer.py腳本訓(xùn)練完先跑三張訓(xùn)練集圖片驗證“[原圖人數(shù) 密度圖求和 × 縮放系數(shù)]”這條等式再去做完整測試集評估。4.3 可視化監(jiān)控預(yù)測密度圖與真值對比用眼睛找問題數(shù)值指標(biāo)會騙人但圖像不會。每訓(xùn)練 5 輪我會保存一組對比圖左邊是原圖中間是真值密度圖 heatmap右邊是預(yù)測密度圖 heatmap。用 matplotlib 畫成三列并排一眼就能看出問題。如果預(yù)測密度圖是“一堆離散亮點”而不是“一片連續(xù)霧狀”說明模型學(xué)成了檢測器大概率是高斯核 sigma 設(shè)太小了如果預(yù)測完全“糊成一片”沒有峰值說明網(wǎng)絡(luò)容量不夠或訓(xùn)練不足。課程設(shè)計文檔里放這種可視化對比圖是特別加分的呈現(xiàn)方式它比丟一串 loss 數(shù)字直觀得多。我會把每輪的對比圖按 epoch 編號存入vis/目錄答辯時挑第 10、30、50 輪的圖放一起展示修正過程。這種“訓(xùn)練過程可視化”在評分老師眼里等同于你在認(rèn)真做實驗而不是在套模板。反過來如果你發(fā)現(xiàn)預(yù)測圖比真值圖“銳利”很多別急著高興這說明網(wǎng)絡(luò)在硬背訓(xùn)練集val MAE 很快就會反彈。5. 密集人群計數(shù)訓(xùn)練避坑五個翻車現(xiàn)場與對應(yīng)解法5.1 密度圖全黑訓(xùn)練損失一直是 0現(xiàn)象訓(xùn)練剛開始 loss 就是 0打印 GT 密度圖發(fā)現(xiàn)全黑完全沒有高斯峰。原因標(biāo)注坐標(biāo)和圖像尺寸沒對齊。最常見的三種一是讀取 mat 時坐標(biāo)順序是 (x, y)但你在 numpy 里用 [y, x] 索引全部落到圖外二是圖像被 resize 后標(biāo)注點沒有同步縮放導(dǎo)致所有點跑出邊界三是 mat 里 points 可能被包了一層直接取到的是對象地址而不是數(shù)組。解決生成密度圖之前先打印points.min(axis0)和points.max(axis0)與img.shape對比。再做兩層保險一是生成時對坐標(biāo)做clip(0, W-1)和clip(0, H-1)二是把超出邊界超過 10% 的樣本直接丟進(jìn)日志提醒。坐標(biāo)順序問題可以臨時畫一張圖驗證把標(biāo)注點用 cv2.circle 畫在原圖上肉眼確認(rèn)位置。這一步 30 秒就能完成能省下兩小時的排錯時間。5.2 loss 降得很低但預(yù)測人數(shù)整體偏大現(xiàn)象訓(xùn)練損失降到 0.001 以下但測試集上預(yù)測人數(shù)普遍比真實人數(shù)多 20%~30%而且原圖越多人差得越離譜。原因密度圖的高斯峰過于尖銳模型輸出的每個峰值都“扎得很高”求和時把一個小區(qū)域的密度放大了。本質(zhì)是 sigma 太小真值密度圖接近 one-hot 分布網(wǎng)絡(luò)只需要學(xué)會“哪里有人頭”就能讓像素級 MSE 很低但總和就不準(zhǔn)。解決把幾何自適應(yīng)高斯核的 spread 從 0.3 調(diào)大到 0.4~0.5讓真值密度圖更平滑同時設(shè)置 sigma 下限例如sigma max(sigma, 4.0)。更穩(wěn)妥的辦法是在訓(xùn)練損失里加一項計數(shù)約束loss mse_loss 0.1 * abs(pred.sum() - gt_count)。這個附加損失直接對齊最終指標(biāo)能有效壓制“像素對但總數(shù)不對”的情況。注意權(quán)重不要超過 0.1否則網(wǎng)絡(luò)會走捷徑輸出一個均勻分布來完成計數(shù)可視化效果很差。5.3 訓(xùn)練集損失下降驗證集 MAE 卻在上升現(xiàn)象前 20 輪一切正常第 25 輪開始 val MAE 突然反彈train loss 還在下降。原因典型的過擬合 學(xué)習(xí)率偏高。MCNN 在沒有預(yù)訓(xùn)練和強數(shù)據(jù)增強的條件下通常在 30~50 輪開始記住訓(xùn)練集的密度峰值位置。如果輸入沒有做隨機裁剪、隨機翻轉(zhuǎn)模型很容易把“訓(xùn)練圖片的固定布局”給背下來。解決加入簡單的數(shù)據(jù)增強水平翻轉(zhuǎn)概率 0.5、隨機亮度擾動、隨機裁剪后 resize 回固定尺寸。同時把學(xué)習(xí)率調(diào)度換成ReduceLROnPlateau監(jiān)測量設(shè)為 val MAEpatience 設(shè) 5factor 設(shè) 0.5。這一步做完val MAE 一般能壓下來 10%~15%。如果還是反彈直接早停用第 25 輪的 checkpoint 重新評估。5.4 測試時預(yù)測密度圖尺寸和輸入對不上count 混亂現(xiàn)象訓(xùn)練時輸入是 384×512測試時直接喂原圖 1024×768模型前向成功了但密度圖求和后的人數(shù)要么少一半要么大幾倍。原因MCNN 全卷積理論上可以接受任意尺寸輸入但池化層會把特征圖縮小 1/2最后密度圖的尺寸是輸入的一半且長寬不一定是偶數(shù)時會出現(xiàn)取整差異。如果測試代碼沒有把 density resize 回原圖尺寸就求和面積比例就錯了。解決有兩種處理方式。一是測試時統(tǒng)一把輸入 resize 到訓(xùn)練尺寸然后按照 4.2 節(jié)的縮放系數(shù)還原人數(shù)這是標(biāo)準(zhǔn)的課程設(shè)計做法。二是測試時保持原圖輸入最后把預(yù)測 density 用 cv2.resize 回原圖尺寸再求和注意要指定interpolationcv2.INTER_CUBIC。我推薦第一種因為它保證網(wǎng)絡(luò)輸入的分布與訓(xùn)練一致性能更穩(wěn)定。如果你非要嘗試任意尺寸輸入請先跑一張非方形圖片驗證 count 是否合理再上完整測試集。5.5 GPU 顯存爆掉batch size 調(diào)到 1 都跑不起來現(xiàn)象報錯CUDA out of memorybatch size 已經(jīng)是 1輸入圖 1024×768 也放不下。原因MCNN 雖然參數(shù)量小但中間特征圖很大。三列分支會在不同分辨率上保留多份特征融合后的 72 通道特征圖占用的顯存遠(yuǎn)超 ResNet-18。一些教程里直接拿原圖訓(xùn)練是導(dǎo)致顯存爆炸的主因。解決把輸入最長邊縮到 800 或直接固定 512×384這是性價比最高的方式。還有一種常見做法是隨機裁剪出 384×384 的 patch 做訓(xùn)練推理時再全圖輸入這樣既控制了顯存又給模型提供了多尺度樣本。注意裁剪時要同步把真實的密度圖裁出來并且記錄裁剪偏移否則人數(shù)對不上。如果縮到 512×384 還爆可以把三個分支的通道數(shù)從 24 降到 12參數(shù)量和顯存會再砍一半課程設(shè)計精度損失完全可以接受。6. 從大作業(yè)到能講清楚的成果兩個驗證技巧和一條存檔習(xí)慣模型訓(xùn)完、指標(biāo)達(dá)標(biāo)之后還有一個常被忽略的環(huán)節(jié)如何證明模型真的學(xué)到了“尺度不變性”而不是背住了訓(xùn)練集的密度分布。我常用的第一個技巧是尺度一致性驗證挑一張測試圖分別縮放到 0.75 倍和 1.25 倍輸入網(wǎng)絡(luò)兩次預(yù)測人數(shù)應(yīng)落在合理偏差范圍內(nèi)比如 5% 以內(nèi)。如果兩次計數(shù)差超過 20%說明模型只在某個固定尺度上有效泛化能力不足。應(yīng)對辦法是給訓(xùn)練集加多尺度隨機縮放增強每張圖在 0.8~1.2 倍之間隨機縮放再 resize 到固定輸入尺寸。第二個技巧是對預(yù)測密度圖做一次 3×3 均值平滑后再求和。這個操作看似多余實際能有效抵消模型輸出中的高頻噪聲。我見過同一張圖直接求和得到 587 人平滑后 562 人而真實值是 551 人平滑后的結(jié)果明顯更準(zhǔn)。原因在于密度圖的理想形式是“連續(xù)分布”而網(wǎng)絡(luò)輸出的峰值往往帶毛刺平滑相當(dāng)于在求和前做了一次低通濾波濾掉了落在人頭上方的孤立尖峰。實現(xiàn)就一行cv2.blur(density, (3, 3))不會引入明顯誤差。最后說一條我認(rèn)為最重要的習(xí)慣固定隨機種子和輸出目錄。當(dāng)初我調(diào)參時因為忘了固定 seed每次訓(xùn)練結(jié)果都微變導(dǎo)致我誤判某個改進(jìn)有效折騰了兩天才發(fā)現(xiàn)是隨機性在作怪?,F(xiàn)在的做法是每次訓(xùn)練前打印torch.manual_seed(42)同時把每個 checkpoint 對應(yīng)的訓(xùn)練參數(shù)表、loss 曲線圖、可視化對比圖統(tǒng)一丟進(jìn)以時間戳命名的目錄。這樣回頭排查“為什么這輪 MAE 高”“為什么密度圖變了”時每一份記錄都能對上號。教訓(xùn)是結(jié)果好記不住過程等于沒做過實驗。希望這套流程能幫你的課程設(shè)計少踩幾個坑把精力花在真正值得調(diào)試的地方。本文還有配套的精品資源點擊獲取