
简介Visdrone2021_CrowdCounting.rar 面向无人机视觉监控方向的研究者与算法工程师聚焦复杂场景下的人群计数任务属于 VisDrone2021 系列中的 Crowd Counting 专项。资源覆盖多种光照、天气与人群密度条件下的无人机视角图像可用于训练与评测基于密度图估计、多尺度特征融合及 CNN、U-Net 等深度学习模型的人群计数方案。压缩包共 10854 个文件以 7236 个 jpg 图像和 3616 个 xml 标注为主另含 2 个 py 脚本整体约 820.44MB图像与标注一一对应便于直接构建训练集与验证集。data_processing 模块提供预处理、标注分析与评估工具可辅助完成图像增强、目标检测与计数指标计算。已有 1538 人学习适合需要真实无人机视角数据开展实验、复现与算法优化的读者参考使用。1. 从 Visdrone2021_CrowdCounting.rar 说起无人机视角下的人群计数到底难在哪无人机俯拍的人群计数和地面监控里数人头完全不是一回事。地面摄像头视角低、人占像素多、遮挡关系简单而无人机在 20 到 80 米高度俯拍一个人可能只有 10 到 30 个像素高密集场景里人头连成一片边缘、纹理、颜色全都糊在一起。Visdrone2021_CrowdCounting.rar 这个标题指向的就是基于 VisDrone 2021 数据集做人群计数Crowd Counting的一套工程方案。它要解决的问题很具体给定一张无人机视角的航拍图估计出画面里的人数并尽量给出密度分布。适合谁做安防巡检、大型活动人流监测、景区与交通枢纽人流评估的算法工程师以及想拿航拍数据集练密度估计回归的开发者。这篇笔记按「数据怎么准备、模型怎么搭、训练怎么调、坑在哪」的顺序讲透新手能照着跑熟手能直接看参数边界。2. 数据准备把 VisDrone 标注转成密度图标签人群计数模型绝大多数走的是密度图回归路线而不是直接回归一个数字。原因很直接直接回归人数一张图只有一个监督信号梯度太稀疏密度图给每个像素都有监督模型能学到「人往哪聚」的空间分布泛化更稳。所以第一步不是搭网络而是把 VisDrone 的检测框标注转成密度图标签。这一步做错后面训练再花哨都是白费。2.1 为什么用高斯核生成密度图而不是数框VisDrone 的标注是目标检测格式每行一个框包含左上角坐标、宽高、类别、截断程度等字段。人群计数需要的是逐像素密度。常见做法是把每个标注框的中心点当作一个人头位置在该位置放一个归一化的二维高斯核所有高斯核叠加得到一张密度图密度图所有像素求和约等于人数。这里有个关键参数高斯核的标准差 σ。σ 太小密度图退化成一个个孤立的点模型学到的是点检测而不是密度估计遇到密集区域会崩σ 太大密度图过度平滑人数估计系统性偏低。工程上的经验做法是按人头在图像中的平均尺寸自适应设定 σ或者用固定 σ 加多尺度训练来兜底。VisDrone 里人头尺寸随飞行高度变化很大我一般会先统计标注框高度的中位数把 σ 设成这个中位数的 0.3 到 0.5 倍再在验证集上看 MAE 微调。2.2 标注解析与密度图生成的完整脚本下面这段脚本做三件事读 VisDrone 的标注文件、过滤掉无效类别和过小目标、生成密度图并保存。目录结构假设为data/VisDrone/annotations/和data/VisDrone/images/实际路径按自己的解压结果改。import os import numpy as np import cv2 from scipy.ndimage import gaussian_filter # VisDrone 类别里 0 表示忽略区域1 表示 pedestrian这里只保留行人 VALID_CLASSES {1} MIN_BOX_H 8 # 过滤高度小于 8 像素的框噪声太大 SIGMA_RATIO 0.4 # sigma 框高中位数 * 该比例 def parse_annotation(txt_path): boxes [] with open(txt_path, r) as f: for line in f: parts line.strip().split(,) if len(parts) 8: continue x, y, w, h map(float, parts[:4]) cls int(parts[5]) if cls not in VALID_CLASSES or h MIN_BOX_H: continue boxes.append((x, y, w, h)) return boxes def make_density_map(boxes, img_shape, sigma): h, w img_shape[:2] density np.zeros((h, w), dtypenp.float32) for (x, y, bw, bh) in boxes: cx, cy int(x bw / 2), int(y bh / 2) if 0 cy h and 0 cx w: density[cy, cx] 1.0 # 用高斯滤波把点扩散成密度分布再归一化保证求和等于人数 density gaussian_filter(density, sigmasigma, modeconstant) total density.sum() if total 0: density density / total * len(boxes) return density def build_dataset(ann_dir, img_dir, out_dir): os.makedirs(out_dir, exist_okTrue) for name in os.listdir(ann_dir): if not name.endswith(.txt): continue stem name[:-4] img_path os.path.join(img_dir, stem .jpg) if not os.path.exists(img_path): continue boxes parse_annotation(os.path.join(ann_dir, name)) if len(boxes) 0: continue img cv2.imread(img_path) heights [b[3] for b in boxes] sigma max(2.0, np.median(heights) * SIGMA_RATIO) density make_density_map(boxes, img.shape, sigma) np.save(os.path.join(out_dir, stem .npy), density) if __name__ __main__: build_dataset(data/VisDrone/annotations, data/VisDrone/images, data/density)逻辑说明parse_annotation里parts[5]是类别字段VisDrone 的字段顺序是框坐标、宽高、类别、截断、遮挡不同年份版本字段位置可能略有差异跑之前先打印一行原始标注确认。make_density_map先把人头中心点累加到单通道图上再用gaussian_filter扩散最后按len(boxes)重新归一化保证密度图求和严格等于人数——这一步不做高斯滤波会改变总能量标签和真实人数对不上训练时 loss 会一直有偏。参数说明MIN_BOX_H是过滤阈值VisDrone 里大量 5 像素以下的小框基本是误标或极远目标留着只会让密度图出现毛刺SIGMA_RATIO控制平滑程度0.3 偏锐、0.5 偏糊建议在 0.35 到 0.45 之间扫一遍。生成完建议随机抽 5 张密度图叠加到原图上肉眼检查密度峰值是否落在人头位置这一步能提前发现字段解析错位的问题。2.3 训练集与验证集的划分边界VisDrone 官方划分里训练集和验证集来自不同场景序列。人群计数任务上如果直接按官方划分验证集场景和训练集差异大MAE 会偏高但更真实如果自己随机打乱划分指标会好看很多但那是自欺欺人。我的建议是先用官方划分跑通确认模型能收敛做对比实验时再固定一套随机种子划分保证不同模型之间可比。另外VisDrone 里有些图人数极少个位数有些图几百人密度差异极大训练时按人数分桶采样避免小图被大图梯度淹没。3. 模型搭建从 CSRNet 到轻量密度回归网络数据准备好之后模型选型决定了上限和落地成本。人群计数这个方向CSRNet 是绕不开的基线前端用 VGG16 的前若干层做特征提取后端接一串空洞卷积扩大感受野最后输出单通道密度图。它的好处是结构简单、复现容易、论文指标可对照坏处是参数量大无人机场景要部署到边缘设备时吃不消。所以实际工程里常见两条路一是 CSRNet 做精度基线二是自己搭轻量网络做部署。3.1 CSRNet 结构拆解与关键层参数CSRNet 的前端直接复用 VGG16 的 conv1 到 conv4输出 stride 为 8 的特征图后端是 6 层空洞卷积 dilation 依次为 2、2、2、2、2、2感受野逐层叠加保证在 stride 8 的特征图上仍能覆盖大范围人群。最后一层 1x1 卷积把通道压到 1得到密度图。输入图像通常缩放到 768 或 1024 宽密度图尺寸是输入的 1/8训练时标签也要对应下采样。这里有个容易翻车的点CSRNet 原论文的密度图标签是下采样到 1/8 分辨率的如果你用全分辨率密度图去监督 1/8 输出尺寸对不上直接报错如果强行 resize密度总和会变人数估计就偏了。正确做法是生成密度图时就在目标分辨率上做高斯扩散或者对全分辨率密度图做 sum pooling 下采样不是平均池化保证下采样后求和仍等于人数。3.2 一个可训练的轻量密度回归网络部署向的方案我一般用「轻量骨干 多尺度融合 密度头」。骨干用 MobileNetV2 的前几层输出 stride 8 特征接一个轻量 ASPP 模块做多尺度上下文最后 1x1 卷积出密度图。下面给出核心网络定义PyTorch 实现。import torch import torch.nn as nn import torchvision class DensityHead(nn.Module): def __init__(self, in_ch128): super().__init__() self.conv1 nn.Conv2d(in_ch, 64, 3, padding1) self.conv2 nn.Conv2d(64, 32, 3, padding1, dilation2) self.conv3 nn.Conv2d(32, 1, 1) self.relu nn.ReLU(inplaceTrue) def forward(self, x): x self.relu(self.conv1(x)) x self.relu(self.conv2(x)) return self.conv3(x) class LiteCounter(nn.Module): def __init__(self): super().__init__() backbone torchvision.models.mobilenet_v2(weightsNone).features # 取到 stride 8 的那一层索引按 torchvision 版本可能不同跑前打印确认 self.backbone backbone[:14] self.head DensityHead(in_ch160) def forward(self, x): feat self.backbone(x) # 输出 stride 8 density self.head(feat) # 1 通道密度图 return density逻辑说明backbone[:14]截取 MobileNetV2 到 stride 8 的位置不同 torchvision 版本层索引会变第一次跑先print(backbone)看输出尺寸确认特征图是输入的 1/8 再固定索引。DensityHead里第二层用了 dilation2在不增加参数的前提下扩大感受野缓解密集区域密度估计断裂的问题。输出没有接 ReLU因为密度理论上非负但训练初期允许小负值有助于收敛推理时再 clamp 到 0。参数说明in_ch160要和骨干输出通道对齐MobileNetV2 在 stride 8 处是 160 通道写错直接维度报错。输入尺寸建议固定为 512x512 或 768x768 的倍数因为 stride 8 要求宽高能被 8 整除否则最后特征图和标签尺寸差一两个像素loss 计算会报 shape mismatch。3.3 损失函数MSE 之外为什么还要加感知损失密度图回归最常用的是 MSE但它对密集区域和稀疏区域一视同仁导致模型在稀疏区域过度优化、密集区域欠拟合。工程上常见的改进是加一项基于局部计数的损失把预测密度图和标签密度图都切成固定大小的块比如 16x16对每块求和得到局部人数再对这些局部人数算 L1 或平滑 L1。这样模型在密集块上的误差会被放大逼它把密集区域学准。两项损失的权重我一般设 MSE 为 1.0、局部计数损失为 0.1 到 0.5具体看验证集 MAE 调。4. 训练调参与评估MAE、MSE 和那块不能省的验证集训练人群计数模型指标只看 MAE 是不够的。MAE 衡量平均绝对误差MSE 对大误差更敏感两个都要看。更重要的是分场景看把验证集按人数分成稀疏小于 50、中等50 到 200、密集大于 200三档分别算 MAE你会发现模型往往在密集档崩得最厉害而整体 MAE 被稀疏档拉得很好看。这个分档评估是判断模型能不能上线的关键。4.1 训练循环与学习率策略下面是一个最小可跑的训练循环包含分档评估。优化器用 Adam初始学习率 1e-4余弦退火到 1e-6batch size 按显存定8 到 16 之间。import torch from torch.utils.data import Dataset, DataLoader import numpy as np class DensityDataset(Dataset): def __init__(self, img_dir, den_dir, size512): self.img_dir, self.den_dir, self.size img_dir, den_dir, size self.names [n[:-4] for n in os.listdir(den_dir) if n.endswith(.npy)] def __len__(self): return len(self.names) def __getitem__(self, idx): import cv2 name self.names[idx] img cv2.imread(os.path.join(self.img_dir, name .jpg)) img cv2.resize(img, (self.size, self.size)) img img[:, :, ::-1].astype(np.float32) / 255.0 img torch.from_numpy(img).permute(2, 0, 1) den np.load(os.path.join(self.den_dir, name .npy)) den cv2.resize(den, (self.size // 8, self.size // 8), interpolationcv2.INTER_AREA) den den / (den.sum() 1e-6) * np.load(os.path.join(self.den_dir, name .npy)).sum() return img, torch.from_numpy(den).unsqueeze(0).float() def train_one_epoch(model, loader, optimizer, device): model.train() total_loss 0 for img, den in loader: img, den img.to(device), den.to(device) pred model(img) loss torch.mean((pred - den) ** 2) optimizer.zero_grad() loss.backward() optimizer.step() total_loss loss.item() return total_loss / len(loader)逻辑说明DensityDataset里对密度图做了INTER_AREA下采样然后按原始总人数重新归一化——这一步是保证下采样后人数不丢的关键直接 resize 会让总和漂移。训练循环里只写了 MSE实际要加局部计数损失的话在loss后面再拼一项即可。设备选择上无人机场景数据量大单卡 8G 显存跑 512 输入、batch 8 基本够用。参数说明size512是输入分辨率越大密集区域细节越多但显存吃紧size // 8对应网络 stride 8 的输出尺寸改输入尺寸时这里要同步。学习率 1e-4 对 Adam 偏保守收敛慢但稳想快可以上 3e-4但要盯着 loss 有没有震荡。4.2 分档 MAE 评估脚本与结果解读torch.no_grad() def evaluate(model, loader, device): model.eval() buckets {sparse: [], medium: [], dense: []} for img, den in loader: img img.to(device) pred model(img).cpu() gt_count den.sum().item() pred_count pred.clamp(min0).sum().item() err abs(pred_count - gt_count) if gt_count 50: buckets[sparse].append(err) elif gt_count 200: buckets[medium].append(err) else: buckets[dense].append(err) for k, v in buckets.items(): if v: print(f{k}: MAE{np.mean(v):.2f}, n{len(v)})逻辑说明pred.clamp(min0)是因为网络输出可能有小负值负密度没有物理意义求和前必须截断。分档阈值 50 和 200 是按 VisDrone 的人数分布定的换数据集要重新看分布再定。如果 dense 档 MAE 明显高于 sparse 档两三倍说明模型对密集区域欠拟合优先加局部计数损失或增大输入分辨率而不是盲目加训练轮数。4.3 数据增强里哪些能用哪些会帮倒忙水平翻转、随机裁剪、亮度对比度扰动都可以用对密度图做对应变换即可。但要注意随机裁剪时如果裁掉了大量人头密度图求和会骤降模型会学到「图里人少」的虚假关联。正确做法是裁剪后重新统计框中心是否落在裁剪区域内只保留中心点在区域内的密度贡献或者干脆用整图缩放代替裁剪。旋转增强在航拍场景要慎用因为无人机视角有固定的俯仰关系大角度旋转会破坏这种先验小角度正负 10 度内可以试。5. 避坑与排查五个让 MAE 突然翻倍的真实原因5.1 密度图求和和标注人数对不上现象训练 loss 能降但验证集预测人数系统性偏低 10% 到 20%。原因生成密度图时高斯滤波后没有重新归一化或者下采样用了平均池化导致总能量损失。解决在生成脚本和 Dataset 里都加一道断言abs(density.sum() - len(boxes)) 1e-3不满足就打印文件名排查。这个断言我建议一直留着成本极低。5.2 输入尺寸不是 stride 的整数倍现象训练时报 shape mismatch或者 loss 计算时广播出诡异结果。原因网络 stride 8输入宽高不能被 8 整除时输出特征图比标签少一两个像素。解决Dataset 里 resize 到 8 的倍数或者在网络最后加一次插值对齐到标签尺寸。后者更稳但插值方式要用双线性且之后重新归一化。5.3 验证集指标虚高但上线就崩现象验证集 MAE 很低换一批真实航拍图预测人数差得离谱。原因训练和验证来自同一批场景序列模型记住了背景纹理而不是人。解决按场景划分而不是按图片随机划分VisDrone 的序列编号字段可以用来做分组划分同一序列的图只能出现在一个集合里。5.4 学习率过大导致密度图全零现象训练几个 epoch 后预测密度图全为 0MAE 等于平均人数。原因MSE 损失下模型发现输出全零的 loss 比乱预测低陷入退化解。解决降低初始学习率到 1e-4 以下或者在损失里加一项对预测总和的约束逼模型至少把人数估对。这个坑在密集图上尤其常见血泪经验。5.5 显存溢出被误判为模型太大现象batch size 调到 4 还 OOM。原因密度图标签是全分辨率 float32一张 1024x1024 就是 4MB加上中间特征图显存占用远超预期。解决标签在 Dataset 里就下采样到网络输出分辨率再返回不要在全分辨率上做 loss另外用torch.cuda.amp混合精度显存能省近一半。6. 进阶技巧用切片推理把大图 MAE 再压一档无人机原图动辄 2000 像素以上直接缩放到 512 训练和推理密集小目标会丢细节。一个成本很低但效果明显的技巧是切片推理把大图切成有重叠的 512 块逐块预测密度图再按重叠区域加权拼回全图最后求和得人数。重叠比例设 0.25 到 0.5重叠区用高斯权重融合避免拼接缝处密度断裂。def sliding_inference(model, img, patch512, overlap0.25, devicecuda): stride int(patch * (1 - overlap)) h, w img.shape[:2] density np.zeros((h // 8, w // 8), dtypenp.float32) weight np.zeros_like(density) for y in range(0, h, stride): for x in range(0, w, stride): y2, x2 min(y patch, h), min(x patch, w) y1, x1 max(0, y2 - patch), max(0, x2 - patch) crop img[y1:y2, x1:x2] crop_t torch.from_numpy(crop).permute(2, 0, 1).float().unsqueeze(0).to(device) / 255.0 with torch.no_grad(): pred model(crop_t).clamp(min0).cpu().numpy()[0, 0] ph, pw pred.shape density[y1 // 8:y1 // 8 ph, x1 // 8:x1 // 8 pw] pred weight[y1 // 8:y1 // 8 ph, x1 // 8:x1 // 8 pw] 1.0 density density / np.maximum(weight, 1e-6) return density.sum()逻辑说明切片坐标按 stride 滑动边界处用max(0, ...)回拉保证每块都是 patch 大小避免边缘块尺寸不足导致网络报错。融合用简单平均权重重叠区被多次预测后取均值比直接覆盖稳。参数上overlap0.25是速度和精度的折中密集场景可以提到 0.5代价是推理时间翻倍。验证这套切片推理有没有用方法很直接拿验证集里人数最多的 20 张图分别用整图缩放推理和切片推理算 MAE如果切片版在密集档低 15% 以上就值得上。我自己的习惯是每次改完推理流程先跑这 20 张的对比再决定要不要全量重跑省时间也省心。这套流程从数据转换到切片推理走下来一个中等规模的航拍人群计数任务基本能在一到两周内跑出可交付的基线剩下的就是按分档指标反复磨密集场景。希望帮到你。本文还有配套的精品资源点击获取