VisDrone人群计数:从检测框到密度图的回归实战
简介Visdrone2021-CC人群计数数据集是VisDrone系列2021年版本中的专项资源面向无人机视觉监控及计算机视觉研究人员旨在解决高密度、复杂背景下的人群数量精确估计问题。压缩包共包含10854个文件总大小约820MB其中有7236张JPEG图像涵盖RGB与热成像、3616个XML标注文件及2个Python辅助脚本图像覆盖不同光照、天气和人群密度场景XML标注可直接用于训练检测或密度回归模型。资源附带的数据处理脚本支持图像增强与评估指标计算可帮助研究者快速构建从预处理到模型验证的实验流程结合热成像数据还能提升低光照或遮挡场景下的鲁棒性。目前已有1536人学习下载适合作为无人机视觉人群计数算法研究、课题实验以及智能监控系统开发的参考数据。1. VisDrone2021_CrowdCounting.rar解压后先别急着跑这个任务的本质是密度回归解压 VisDrone2021_CrowdCounting.rar 之后大部分人第一反应是找训练好的权重跑一遍测试集然后卡在数据加载上。原因是 VisDrone2021 这套数据集的标注格式是目标检测的边界框而 CrowdCounting 任务真正需要的是密度图画面里每个可见人头对应一个高斯峰。无人机俯视视角下密集人群的头部在图上互相重叠检测框严重时会大面积漏检算检测框个数或者直接统计检测目标都是不可靠的路线。这个压缩包的方向是在 VisDrone2021 的俯视视频序列上训练一个端到端的密度回归模型把每帧图像转成一张密度图再对密度图求和得到人数。它适合两类人一是做过检测但没接触过密度回归的工程师二是刚拿到数据却找不到标注转换入口的开发者。真正决定项目成败的不是模型结构而是把检测框标注转换成人头点、再变成可训练密度图的那一步这篇文章就把这条链路完整拆开。2. 人群计数为什么选密度图回归从 VisDrone 的标注里挑出计数信息2.1 密度图回归 vs 检测框计数俯视密集场景的第一性原理地面监控的人群计数检测框方案还能勉强工作因为人头之间有足够间距。但无人机挂高之后视角变成俯视人的头顶在成像面上是一个个小圆斑密集时互相吞没。检测模型里的 anchor 匹配、NMS 合并、小目标阈值每一步都在丢失人头。你在 VisDrone 上做检测会发现预测框数量比实际人头数少很多这不是模型能力问题是表征方式错了。密度图回归换了一个思路不判断“这里有一个人”而是对每个像素输出一个人头密度值。训练时把每个人头中心点变成一个高斯核真实地面图就是把所有人头的高斯核叠加起来预测时对整张密度图求和得到的就是人数。它的好处从根上避开了遮挡问题两个人头在图像上重叠高斯峰也重叠但积分总量还是两个人模型不需要分辨谁是谁。数学上密度图可以写成D(p) Σ_i G_σ(p - p_i)其中 p_i 是第 i 个人头中心点坐标G_σ 是标准差为 σ 的高斯核。给定一张密度图人数就是C Σ_p D(p)这套公式是后面所有代码的基础VisDrone 的标注里没有 p_i需要先从 bbox 里提取出来。2.2 解析 VisDrone 标注文件把 bbox 中心转成人头点标注VisDrone 的标注是 txt 文件每一行对应一个目标字段用逗号分隔。包含帧号、目标 ID、bbox 的左上角坐标和宽高、score、类别、截断程度、遮挡程度、ignore 标志。做人群计数最关心的是 frame、bbox 中心、类别和 ignore。常见的 VisDrone 标注行1,1,639,402,80,120,1,1,0,0,0第一个字段是帧号第二是目标 ID第三到第六是 bbox 的 left、top、width、height第八个是类别。类别里 pedestrian 是 1people 是 2两者都要纳入计数。people 类在标注里表示“一群人”框往往很大直接取框中心当人头点会出错这在后面会专门处理。第一步先写一个解析脚本把 bbox 中心转成“帧号 像素坐标”的点列表 visdrone_to_points.py 把 VisDrone 的 bbox 标注转成人头点列表。 只保留 pedestrian(1) 和 people(2) 两类 ignore1 的框直接丢弃避免把低置信度目标当训练点。 输出格式: frame x y import os import glob from collections import defaultdict COUNTING_CLASSES {1, 2} # pedestrian、people def convert_annotation(txt_path): points_by_frame defaultdict(list) with open(txt_path, r, encodingutf-8) as f: for line in f: parts line.strip().split(,) if len(parts) 11: continue frame int(parts[0]) obj_id int(parts[7]) x1 float(parts[2]) y1 float(parts[3]) w float(parts[4]) h float(parts[5]) ignore int(parts[10]) if obj_id not in COUNTING_CLASSES: continue if ignore 1: continue if h 5: continue # people 类是大框只有小框可以当作单个人头 if obj_id 2 and w 48: continue cx x1 w / 2.0 cy y1 h / 2.0 points_by_frame[frame].append((cx, cy)) return points_by_frame def main(data_root, out_dir): os.makedirs(out_dir, exist_okTrue) for txt in sorted(glob.glob(os.path.join(data_root, **, *.txt), recursiveTrue)): seq os.path.basename(os.path.dirname(txt)) points convert_annotation(txt) out_path os.path.join(out_dir, f{seq}.txt) with open(out_path, w, encodingutf-8) as f: for frame in sorted(points.keys()): for x, y in points[frame]: f.write(f{frame} {x:.2f} {y:.2f}\n) print(f{seq}: {sum(len(v) for v in points.values())} points) if __name__ __main__: main(./VisDrone2021/train/, ./points/train/)这里几个过滤条件的逻辑要说明一下。h 5 的框去掉是因为一个人在图像里只有五六个像素高时高斯核再小也会把相邻目标糊成一片这类点对密度回归没有正面作用。ignore 1 的框是标注方标记的低置信度或严重遮挡目标不该作为训练监督信号。people 大框的过滤是要单独解释的people 类本意是“给一群人一个大范围框”一个框里可能有三五个人头取中心当一个人头会让真实人数偏小宽度超过 48 像素的 people 框直接丢弃小于这个值的通常是单人靠近镜头的特殊情况框中心基本等于人头中心。这个阈值会根据你的图像分辨率变化用原图跑的话 48 可以作为起点。解析脚本输出的点列表是接下来的数据基础不要再回到原始标注去取框了所有密度图都从点列表生成。3. 把 VisDrone 的框标注变成密度图两套生成脚本与参数选择3.1 固定高斯核与自适应高斯核的选择拿到人头点列表后下一步是生成密度图。最简单的做法是给每个点固定一个 σ比如 σ 15然后叠加高斯核。这个方案在人群稀疏、人头大小均匀的地面监控视频里够用但放到 VisDrone 上很快会出问题。无人机画面里近处的人头在图上占四五十像素远处的人头只有六七个像素固定 σ 会把远处的小头糊成一个平缓的大包多个小头的高斯核叠在一起直接变成一个没有区分度的区域密度图的总量尽管还在空间分布已经完全错位训练出来的模型只会“估个数”不会“找人”。解决方法是自适应高斯核核心思想是人头越密、核越小σ_i β · mean(d_1, d_2, d_3)其中 d_1 到 d_3 是第 i 个人头到最近三个邻居的欧氏距离β 是缩放系数。这个公式最早是人群密度估计的常见做法用在 VisDrone 上的关键是要把 β 和上下限设对。 compute_adaptive_sigma.py 对每个点计算自适应高斯核的 sigma。 import numpy as np def adaptive_sigma(points, beta0.3, k3, min_sigma2.0, max_sigma20.0): pts np.array(points, dtypenp.float32) n len(pts) sigmas np.ones(n, dtypenp.float32) * min_sigma for i in range(n): diff pts - pts[i] # (n, 2) 所有点到当前点的偏移 dist np.sqrt((diff ** 2).sum(axis1)) dist[i] np.inf # 自己不算邻居 nearest np.sort(dist)[:k] sigma beta * nearest.mean() sigmas[i] np.clip(sigma, min_sigma, max_sigma) return sigmas参数选择有实际依据。β 取 0.3 是大多数密集人群场景的默认值因为近距离邻居的平均距离大概能反映人头之间的实际间隔取三成作为高斯核标准差出来的核既不会糊掉邻居也不会变成孤立的尖点。当一帧超过 80 人、人头极度密集时β 调到 0.2 会更稳画面空旷、每帧只有二三十人时 0.5 反而更好。min_sigma 设为 2 是因为小于 2 像素的高斯核退化成单个像素点模型几乎学不到有效梯度。max_sigma 设为 20 是因为无人机俯视场景里人头投影尺寸有限σ 超过 20 后核的覆盖范围太大会把稀疏场景里几个相邻的人头融成一个峰密度图求和虽然还是那些人但空间监督信号已经失真。3.2 密度图生成脚本与归一化细节有了每个点的 σ就可以把点列表转成和原图一样大小的密度图。这个脚本建议在数据预处理阶段跑一遍把所有帧的密度图存成 npy 文件训练时直接读省去 DataLoader 里重复计算的开销。磁盘占用会大一些但能避免训练中断时重新算密度图的等待。 generate_density_map.py 按帧生成自适应高斯核密度图。 密度图和原图分辨率一致保存为 npy。 import numpy as np import cv2 def render_density(image_h, image_w, points, sigmas): density np.zeros((image_h, image_w), dtypenp.float32) if not points: return density pts np.array(points, dtypenp.float32) for (x, y), sigma in zip(pts, sigmas): # 核尺寸取 6 倍标准差并保证是奇数 size int(sigma * 6) if size % 2 0: size 1 kernel cv2.getGaussianKernel(size, sigma) kernel2d kernel kernel.T kernel2d / kernel2d.sum() # 让核的积分等于 1 x0 int(round(x - size // 2)) y0 int(round(y - size // 2)) x1 x0 size y1 y0 size # 裁剪超出图像边界的部分 left max(0, -x0) top max(0, -y0) right max(0, x1 - image_w) bottom max(0, y1 - image_h) cut kernel2d[top:size - bottom, left:size - right] x0 max(0, x0) y0 max(0, y0) x1 min(image_w, x1) y1 min(image_h, y1) density[y0:y1, x0:x1] cut return density这里有一个细节值得单独说高斯核除以自身总和之后单个核的积分是 1多个核叠加后密度图的积分正好等于这一帧的总人数。这个性质是密度图方法正确性的基石训练时损失函数拿预测密度图的积分去和真实人数比对才有意义。如果你把核归一化这一步漏了每个核的实际面积是网格点数而不是 1密度图积分会比真实人数大好几倍MSE 一上来就下不去。图像进模型之前通常要缩放到统一尺寸密度图也必须同步缩放。最简单的处理是等比缩放短边到 960、长边不超过 1920缩放时记住 scale 和原尺寸对密度图用 cv2.resize 的 INTER_AREA 插值同时把点坐标也乘上 scale。等比缩放后的密度图积分理论上不变但插值会引入微小误差严格的做法是缩放后重新对密度图做归一化每张密度图除以自身总和、再乘以原图人数。这一步没那么玄但能避免损失值在训练过程中出现意料之外的小幅波动。4. 训练配置与损失函数从零跑通一个无人机人群计数模型4.1 骨干网络与关键训练参数照抄能起跑的最小配置密度图模型的主流选择还是 CSRNet 这条路线用 VGG16 的前半部分做骨干后面接空洞卷积扩大感受野最后上采样回原图尺寸。原因很简单VGG16 是经典特征提取网络预训练权重容易拿到人群计数的重点不在骨干的创新而在密度图监督。无人机俯视画面里人头的纹理和自然图像的纹理共享边缘、颜色、梯度这些低级特征直接复用 ImageNet 预训练权重比从零开始训练少花至少一半时间。 crowd_counter.py CSRNet 简化版VGG16 前段骨干 空洞卷积分支 双线性上采样。 import torch import torch.nn as nn from torchvision.models import vgg16 class CrowdCounter(nn.Module): def __init__(self): super().__init__() features list(vgg16(pretrainedTrue).features.children()) self.backbone nn.Sequential(*features[:23]) # 输出 512 通道1/8 分辨率 self.head nn.Sequential( nn.Conv2d(512, 256, 3, padding2, dilation2), nn.ReLU(inplaceTrue), nn.Conv2d(256, 128, 3, padding2, dilation2), nn.ReLU(inplaceTrue), nn.Conv2d(128, 64, 3, padding1), nn.ReLU(inplaceTrue), nn.Conv2d(64, 1, 1), ) self.upsample nn.Upsample(scale_factor8, modebilinear, align_cornersFalse) def forward(self, x): x self.backbone(x) x self.head(x) x self.upsample(x) return xfeatures[:23] 取的是 VGG16 前四个卷积块输出 512 通道的特征图分辨率降为输入的 1/8。后面的空洞卷积 dilation2 在保持 1/8 分辨率的同时扩大感受野为的是让每个像素能看到更大范围的人群分布这对无人机大场景尤其重要人群往往是成片出现的。最后用双线性上采样把密度图拉回原图尺寸是工程上的简化做法效果足够要更高精度就把上采样换成两层转置卷积但参数量和训练时间都会上涨。训练参数直接给一份能跑通的默认配置。参数推荐值说明输入短边960等比缩放长边不超过 1920batch size43090 级别显卡可以开到 8优化器AdamWweight decay 设为 5e-4backbone 学习率1e-5预训练权重只做微调head 学习率1e-4新初始化的层学得快一些总 epoch120第 60 轮之后 lr 乘 0.1损失函数MSE L1比例 1 : 0.5随机裁剪512x768从缩放后的图像里裁剪等比对应密度图学习率分开设置是这里最值得注意的一点。backbone 已经包含成熟特征lr 太大会把预训练权重冲掉尤其是无人机视角图像和 ImageNet 自然图有分布差异冲过头就看不到任何特征了。head 是新初始化的层lr 大一个量级能加快收敛。我自己习惯把 backbone 的 dropout 也关闭密度图任务不需要它对特征做随机置零。4.2 损失函数只盯 MSE 会让密度图变得模糊经典的 CSRNet 只用 MSE回归密度图的每个像素。MSE 的优点是收敛快对极端误差敏感但它有个副作用模型发现把密度图整个压平、把所有峰值分给一片区域MSE 也很小因为积分总量对了。结果是密度图一片模糊人数估算还行真拿去做人群空间分布分析就废了。实际落地时我更推荐 MSE L1 的组合 mixed_loss.py MSE 管总量L1 管逐像素稀疏度两者权重可调。 import torch.nn.functional as F def mixed_loss(pred, gt, w_l10.5): gt gt.float() pred pred.float() # 确保两个张量尺寸一致后再算损失 if pred.shape ! gt.shape: pred F.interpolate(pred, sizegt.shape[-2:], modebilinear, align_cornersFalse) mse F.mse_loss(pred, gt) l1 F.l1_loss(pred, gt) return mse w_l1 * l1MSE 对大误差敏感L1 对每个像素的偏差都给出恒定梯度不会像 MSE 那样对小数像素误差几乎无感。w_l1 取 0.5 起步如果你的密度图出现明显模糊把 w_l1 提到 1.0如果出现密集区域过冲、稀疏区域被抑制就降回 0.2。这个权重不需要调太多轮对最终 MAE 的影响远小于数据过滤规则。如果你还想让密度峰位置更准可以在这个基础上叠加 SSIM 结构损失但要注意密度图的数值范围不固定叠加 SSIM 前需要先对 pred 和 gt 做 min-max 归一化否则大值区域会主导整个梯度。第一版不建议加把 MSEL1 跑通再考虑。4.3 从预训练权重起步为什么不建议随机初始化从头训练一个密度图模型在 VisDrone 上几乎不可能收敛。一方面数据量不够无人机视角下的人群变化比地面视频更复杂头部朝向、遮挡模式、尺度跨度都更大另一方面密度图监督本身没有目标检测那种强定位信号全靠大量数据堆出来。用 VGG16 预训练权重做初始化相当于模型已经知道怎么提取边缘、纹理、形状只需要学会把人头特征映射到密度。如果 PyTorch 版本新到 pretrained 参数报错就换成 weightsIMAGENET1K_V1。加载过程要注意不要把你的 head 层参数算进去只加载 backbone 的前缀。一个常见的坑是加载权重时报 size mismatch原因是 head 里有几个 3x3 卷积的参数和 VGG 分类头同名加载时对不上。用 strictFalse 再手动检查 backbone 是否真的加载成功别让加载错误被静默吞掉。5. VisDrone2021_CrowdCounting 排查手册5 个必须绕开的坑5.1 ignore 区域被当成负样本现象模型预测的密度图在车辆、树荫、建筑边缘出现大片假峰而且这些假峰不在任何标注人头附近。查训练数据发现这些区域像素很暗、纹理规则模型把它们当成了“没有人”的区域来学习。原因VisDrone 的 ignore1 表示该区域目标过小或不可辨认不代表这里没人。数据里还有大量完全没有任何标注的背景区域模型天然会把它们当负样本。ignore 区域既有可能是人、又没有任何正样本标注模型从中学到的规则是“这个地方没有头”这个规则本身是错的。解决解析标注时利用 ignore 框生成一个 valid mask和密度图同尺寸ignore 区域置 0、其他区域置 1损失计算时只对 valid mask 内的像素算等于把这块监督信号挖掉。如果你偷懒不做至少训练几个 epoch 后把 ignore 区域的预测值单独打印出来如果均值明显大于 0.01就得回去补第一步。5.2 固定 sigma 让远处小头直接蒸发现象训练损失下降正常可视化密度图发现远处的人头几乎看不到只有近处几个人头有响应。整体人数误差不小而且集中在远景区域。原因固定 sigma 时远处的 6 像素小头被高斯核摊到几十个像素范围内峰值只有 0.01 量级和背景噪声差不多。MSE 损失在这些点上贡献趋近于零模型学到的是“远景没人”。解决改用自适应 sigma并确认 min_sigma 设到 2.0 附近。生成密度图后抽查远处小目标取一个人头点位置看密度图对应坐标的峰值是否和相邻背景有至少 5 倍差距。如果没有说明 sigma 还是偏大或者该点被过滤掉了。这个抽查脚本值得保留后续每次换数据都要跑一遍。5.3 people 类被按一个点算人数低估现象测试集上预测人数系统性低于真实人数误差在人群密集的视频序列里尤其明显而稀疏序列误差很小。原因标注里的 people 类是给“一群人”打的框一个框覆盖几个人甚至十几个人。只用 bbox 中心当作一个点模型会认为这个位置只有一个人。无人机俯视角度下这类框数量不少累计起来误差就大了。解决处理标注时分三档。宽度小于 40 像素的 people 框保留当作远处单个行人宽度在 40 到 80 之间按框宽高比例估算 2 到 3 个点均匀放在框内超过 80 像素的框直接丢弃因为它通常是密集人群的大范围框无法可靠还原人头位置。如果你做严格的人头计数最稳妥的是只保留 pedestrian 类但要注意这样会丢掉一部分真实人头需要自行权衡。5.4 按帧切分训练/验证导致数据泄漏现象验证集 MAE 很低模型表现非常好但拿到另外一批无人机视频上测试直接崩掉误差翻倍。原因VisDrone 以视频序列组织数据同一个序列里相邻帧的背景几乎一样人和车的位置变化也很小。如果你随机按帧切分同一时刻附近的帧会同时出现在训练和验证集里模型等于背下了这片背景。这不是泛化能力是数据泄漏。解决切分必须按序列进行一个视频序列的所有帧要么全在训练集要么全在验证集。VisDrone 官方给的训练/验证划分本身就是按序列做的直接沿用官方划分不要自己再按帧重新切分。这个原则同样适用于测试集测试序列的无人机飞行轨迹、拍摄地点如果和训练集接近分数只能说明模型在这个数据分布下的表现不代表它能应付完全陌生的场景。5.5 图像 resize 之后密度图与标签错位现象训练时 loss 正常下降可视化预测叠加到原图上发现密度峰整体偏移了十几个像素。越靠近图像边缘偏移越明显。原因图像缩放用了 cv2.resize密度图 resize 时用了最近邻或者缩放后直接改图像尺寸、密度图还保持原图大小导致监督信号和输入图像空间不对齐。前期不明显随着损失收敛到很小的值未对齐像素的误差就暴露出来了。解决图像和密度图必须使用同一套坐标变换参数。等比缩放时先算出 scale图像用 INTER_LINEAR密度图用 INTER_AREA缩放后核对两者的 shape 是否完全一致。裁剪同样要小心random crop 的坐标直接应用到密度图不能单独对密度图再做一次 resize。把这个封装进 Dataset 类里杜绝在训练循环里手写缩放逻辑。6. 模型训完怎么验证在 VisDrone 测试集上算 MAE/MSE再迁移到地面视角6.1 评估脚本密度图求和后和真值比对人群计数公认的两个指标是 MAE 和 MSE。MAE 表达平均绝对误差MSE 对大误差更敏感一个模型两项都不错才说明它稳定。评估时只需要对每帧求预测密度图的积分和真值密度图的积分再比对即可。 evaluate.py 在测试序列上逐帧计算 MAE / MSE。 import numpy as np import glob def evaluate(pred_dir, gt_dir): mae_list [] mse_list [] pred_files sorted(glob.glob(pred_dir /*.npy)) for p_path in pred_files: name p_path.split(/)[-1].replace(_pred.npy, ) g_path f{gt_dir}/{name}_gt.npy pred_map np.load(p_path).astype(np.float32) gt_map np.load(g_path).astype(np.float32) pred_count pred_map.sum() gt_count gt_map.sum() err pred_count - gt_count mae_list.append(abs(err)) mse_list.append(err ** 2) mae float(np.mean(mae_list)) rmse float(np.sqrt(np.mean(mse_list))) print(fMAE: {mae:.3f} RMSE: {rmse:.3f}) return mae, rmse这套评估逻辑要在训练过程中就挂上每 5 个 epoch 跑一次验证集记录 MAE。如果 MAE 在第 30 轮之后开始回升或者抖动剧烈优先怀疑学习率太高或者数据里混入了异常帧而不是模型结构的问题。6.2 自检把密度图可视化成人头位置只看 MAE 数字远远不够密度图模型可能总人数估得准但人头位置全错。数值指标需要配合可视化一起判断。用 maximum_filter 找密度图的局部极大值这些峰值就是模型认为的人头位置。 visualize_peaks.py 从预测密度图中提取局部峰值叠加到原图上检查。 from scipy.ndimage import maximum_filter def extract_peaks(density, min_thresh0.15): local_max maximum_filter(density, size11) density thresh min_thresh * density.max() ys, xs np.where(local_max (density thresh)) return xs, yssize11 对应高斯核约 9 到 11 像素的峰值间距小于这个间隔的相邻峰会被合并。min_thresh 取 0.15 是经验值如果密度图整体都低就把阈值降到 0.05。把提取出来的峰值画到原图上对比标注点如果预测峰出现在人群边界而非人群中心说明自适应 sigma 的 min_sigma 偏大回头调参。6.3 从无人机到地面监控的迁移边界VisDrone 上训好的密度模型直接拿到地面监控视角效果会下降但不至于完全失效。地面画面的人头尺度和无人机中远景的人头尺度接近特征上可以迁移视角从俯视变成斜视之后人头的遮挡方式和背景复杂度都变了直接推理的误差会明显上升。我的习惯是先用原模型做零样本推理统计误差分布挑出误差最大的几条视频做微调通常几百帧数据、微调 20 个 epoch 就能把 MAE 拉回可用水平。密度图模型属于场景敏感型模型迁移能力弱于检测模型这是密度回归方法的固有代价不要指望一个在 VisDrone 上训完的模型能通吃所有视角。最后分享一个我自己的检查习惯任何一次新数据、新预处理流程跑通之后我先随机抽三帧做可视化一帧密集一帧稀疏一帧中等密度确认密度峰的位置和人头重合再开始调损失函数和超参数。如果这三帧都正常后面出现玄学问题大概率出在数据加载顺序或者在线增强上而不是模型本身。这个习惯帮我排掉过高斯核归一化、resize 错位、ignore 过滤失效这几个最常见的坑希望帮到你。本文还有配套的精品资源点击获取