☰
航拍小目标检测YOLOv8改进:从漏检到高召回实战
2026/9/26 19:30:52 网站建设 项目流程

简介:本资源面向计算机视觉研究者与深度学习开发者,聚焦航拍图像场景下的小目标检测难题,提供一套基于改进YOLOv8的完整算法实现与实战项目。针对小目标尺寸小、分辨率低、背景噪声干扰强等痛点,项目对网络结构、损失函数与锚框策略进行优化,增强细粒度特征提取与上下文信息利用,可应用于安防监控、遥感分析、无人机巡检等场景。压缩包共87个文件,约1.97MB,以38个Python源码文件为核心,辅以26个pyc编译文件、18个YAML配置、2张jpg与2张png检测效果图及1份README说明,涵盖模型定义、损失与指标计算、数据配置及可视化模块,目录结构清晰便于复现。已有138人学习下载。读者可获取完整项目源码、训练配置与检测效果展示,快速搭建环境、复现实验并迁移至自有航拍数据集,同时理解小目标检测的改进思路与调优方法。

1. 航拍小目标检测为什么总在“漏检”上翻车

航拍图像里的小目标检测,是目标检测落地里最容易被低估的一类任务。无人机在 100 米以上高度拍摄时,一辆车可能只有 8×10 像素,一个人不到 6×6 像素,一只鸟甚至只有 3×4 像素。这些目标在 YOLOv8 默认的 640×640 输入下,经过 32 倍下采样后,在 P5 特征图上只剩不到 1 个像素的响应,网络根本“看不见”。更麻烦的是航拍场景背景复杂——停车场、农田、建筑屋顶的纹理和车辆、行人高度相似,误检和漏检同时爆发。

这个方向要解决的核心问题就一句话:在保持 YOLOv8 实时推理速度的前提下,把航拍图像中小目标的召回率从“勉强能用”拉到“可以交付”。适合谁做?做遥感图像目标检测的算法工程师、需要交付无人机巡检/安防监控方案的开发者、以及拿 YOLOv8 做毕业设计但发现小目标指标惨不忍睹的学生。下面从数据、结构改进、训练策略到部署验证,把这条链路拆开讲清楚。

2. 航拍小目标数据集怎么处理才能喂给 YOLOv8

2.1 航拍数据的三个特殊性和标注格式转换

航拍图像和 COCO、VOC 那类自然图像有本质区别。第一,目标尺度分布极度偏斜,90% 以上的标注框面积小于 32×32 像素;第二,图像分辨率极高,常见 4000×3000 甚至 8000×6000,直接缩放会丢失小目标信息;第三,目标方向任意,车辆、飞机在航拍视角下没有固定的“上”方向。

常见做法是把标注统一转成 YOLO 格式。假设你用的是 labelme 标注的 JSON,转换脚本如下:

import json import os from pathlib import Path def labelme_to_yolo(json_dir, output_dir, class_map): """ json_dir: labelme json 文件目录 output_dir: 输出 txt 标签目录 class_map: {'car': 0, 'person': 1, ...} """ output_dir = Path(output_dir) output_dir.mkdir(parents=True, exist_ok=True) for json_file in Path(json_dir).glob('*.json'): with open(json_file, 'r', encoding='utf-8') as f: data = json.load(f) img_w = data['imageWidth'] img_h = data['imageHeight'] lines = [] for shape in data['shapes']: label = shape['label'] if label not in class_map: continue points = shape['points'] xs = [p[0] for p in points] ys = [p[1] for p in points] # 计算归一化中心点和宽高 x_center = (min(xs) + max(xs)) / 2.0 / img_w y_center = (min(ys) + max(ys)) / 2.0 / img_h width = (max(xs) - min(xs)) / img_w height = (max(ys) - min(ys)) / img_h # 过滤掉宽高为 0 的异常标注 if width <= 0 or height <= 0: continue lines.append(f"{class_map[label]} {x_center:.6f} {y_center:.6f} {width:.6f} {height:.6f}") txt_path = output_dir / (json_file.stem + '.txt') with open(txt_path, 'w') as f: f.write('\n'.join(lines)) # 使用示例 class_map = {'car': 0, 'person': 1, 'bird': 2} labelme_to_yolo('./annotations', './labels', class_map)

这段代码的关键点在于归一化坐标的计算方式——YOLO 用的是中心点加宽高的归一化值,不是左上角加右下角。class_map必须和后续训练时的data.yaml里names的顺序完全一致,否则类别会错位。另外注意过滤掉宽高为 0 的异常标注,航拍数据里经常出现标注工具误操作产生的退化框,不清理会在训练时产生 NaN 损失。

2.2 切图策略:为什么直接 resize 是灾难

航拍图像动辄 4000×3000,直接 resize 到 640×640 意味着缩小 6 倍以上,一个原本 20×20 像素的小目标变成 3×3 像素,信息基本丢光。我一般用滑窗切图,把大图切成 1024×1024 或 1280×1280 的块,块之间保留 20% 重叠,避免目标被切断。

import cv2 import numpy as np from pathlib import Path def sliding_window_crop(img_path, label_path, output_img_dir, output_label_dir, crop_size=1024, overlap=0.2): """ 滑窗切图,同步裁剪标签 overlap: 重叠比例,0.2 表示 20% 重叠 """ img = cv2.imread(str(img_path)) h, w = img.shape[:2] stride = int(crop_size * (1 - overlap)) # 读取 YOLO 格式标签 labels = [] if Path(label_path).exists(): with open(label_path, 'r') as f: for line in f: parts = line.strip().split() if len(parts) == 5: labels.append([float(x) for x in parts]) crop_idx = 0 for y in range(0, h, stride): for x in range(0, w, stride): x2 = min(x + crop_size, w) y2 = min(y + crop_size, h) x1 = max(0, x2 - crop_size) y1 = max(0, y2 - crop_size) crop_img = img[y1:y2, x1:x2] crop_h, crop_w = crop_img.shape[:2] new_labels = [] for cls, cx, cy, bw, bh in labels: # 转成绝对坐标 abs_cx = cx * w abs_cy = cy * h abs_bw = bw * w abs_bh = bh * h # 判断目标中心是否在裁剪区域内 if x1 <= abs_cx < x2 and y1 <= abs_cy < y2: new_cx = (abs_cx - x1) / crop_w new_cy = (abs_cy - y1) / crop_h new_bw = abs_bw / crop_w new_bh = abs_bh / crop_h # 过滤掉裁剪后面积过小的目标 if new_bw * crop_w * new_bh * crop_h < 16: continue new_labels.append(f"{int(cls)} {new_cx:.6f} {new_cy:.6f} {new_bw:.6f} {new_bh:.6f}") if len(new_labels) == 0: continue stem = Path(img_path).stem cv2.imwrite(str(Path(output_img_dir) / f"{stem}_{crop_idx}.jpg"), crop_img) with open(Path(output_label_dir) / f"{stem}_{crop_idx}.txt", 'w') as f: f.write('\n'.join(new_labels)) crop_idx += 1

crop_size的选择有讲究:太小则单块内目标数量少、上下文信息不足;太大则小目标在块内占比仍然很低。1024 是我在多个航拍数据集上试出来的平衡点。overlap设 0.2 能保证边缘目标至少完整出现在一个块里。过滤面积小于 16 像素的目标是必要的,这些目标连人眼都难以确认,强行训练只会引入噪声。

2.3 data.yaml 配置和数据集划分的坑

切完图之后,目录结构建议这样组织:

dataset/ ├── images/ │ ├── train/ │ ├── val/ │ └── test/ ├── labels/ │ ├── train/ │ ├── val/ │ └── test/ └── data.yaml

data.yaml的内容:

path: /home/user/dataset train: images/train val: images/val test: images/test names: 0: car 1: person 2: bird

这里有个血泪经验:切图之后必须重新划分训练集和验证集,不能按原始大图划分再切图。因为同一张大图切出来的块高度相似,如果按大图划分,验证集里的块和训练集里的块来自同一张大图,验证指标会虚高,实际部署时性能掉得厉害。正确做法是先切图,再对所有切出来的块做随机划分,保证同一张大图的块不会同时出现在训练集和验证集里——更严格的做法是按原始大图分组划分,同一张大图的所有块只进一个集合。

3. 改进 YOLOv8 结构:让小目标在特征图上“活下来”

3.1 增加 P2 检测头:最直接有效的一步

YOLOv8 默认使用 P3、P4、P5 三个尺度的检测头,对应 8 倍、16 倍、32 倍下采样。P3 特征图的感受野对应原图 8×8 像素的区域,对于 8×8 以下的目标,P3 已经力不从心。最直接的改进是增加 P2 检测头,对应 4 倍下采样,特征图分辨率是 P3 的两倍。

在 YOLOv8 的配置文件里加 P2 检测头,需要修改模型的 YAML 定义。以 yolov8n 为例,关键改动是在 backbone 里保留更浅层的特征输出,并在 head 里增加对应的检测分支:

# yolov8n-p2.yaml 关键部分 backbone: # [from, repeats, module, args] - [-1, 1, Conv, [64, 3, 2]] # 0-P1/2 - [-1, 1, Conv, [128, 3, 2]] # 1-P2/4 - [-1, 3, C2f, [128, True]] - [-1, 1, Conv, [256, 3, 2]] # 3-P3/8 - [-1, 6, C2f, [256, True]] # ... 后续层保持不变 head: - [-1, 1, nn.Upsample, [None, 2, 'nearest']] - [[-1, 2], 1, Concat, [1]] # 融合 P2 特征 - [-1, 3, C2f, [128]] # P2 检测分支 # ... P3/P4/P5 分支

增加 P2 检测头后,模型参数量和计算量会上升约 15%~20%,推理速度下降 10% 左右。在 GTX 1660 Ti 上,yolov8n 从约 80 FPS 降到约 70 FPS,但小目标召回率通常能提升 8~15 个百分点。这个 trade-off 在航拍场景下几乎总是值得的。

3.2 注意力机制怎么加才不拖后腿

YOLOv8 改进里注意力机制是绕不开的话题,但很多方案加完注意力之后指标不升反降。问题出在加的位置和方式。航拍小目标检测里,我一般推荐在 neck 部分的 C2f 模块后面加协调注意力(Coordinate Attention),而不是在 backbone 每个 block 后面都加。

协调注意力把特征图分别沿水平和垂直方向做池化,保留位置信息的同时建模通道关系。相比 SE 注意力只做全局池化丢失空间信息,CA 更适合需要精确定位的小目标。代码实现:

import torch import torch.nn as nn class CoordAtt(nn.Module): def __init__(self, channels, reduction=32): super().__init__() self.pool_h = nn.AdaptiveAvgPool2d((None, 1)) self.pool_w = nn.AdaptiveAvgPool2d((1, None)) mip = max(8, channels // reduction) self.conv1 = nn.Conv2d(channels, mip, 1) self.bn1 = nn.BatchNorm2d(mip) self.act = nn.SiLU() self.conv_h = nn.Conv2d(mip, channels, 1) self.conv_w = nn.Conv2d(mip, channels, 1) def forward(self, x): identity = x n, c, h, w = x.size() x_h = self.pool_h(x) # (n, c, h, 1) x_w = self.pool_w(x).permute(0, 1, 3, 2) # (n, c, w, 1) y = torch.cat([x_h, x_w], dim=2) # (n, c, h+w, 1) y = self.conv1(y) y = self.bn1(y) y = self.act(y) x_h, x_w = torch.split(y, [h, w], dim=2) x_w = x_w.permute(0, 1, 3, 2) a_h = self.conv_h(x_h).sigmoid() a_w = self.conv_w(x_w).sigmoid() return identity * a_h * a_w

reduction参数控制中间层的压缩比,32 是原论文的默认值,通道数较少的模型(如 yolov8n 的 neck 部分只有 128 通道)可以调到 16 避免过度压缩。加注意力的位置建议在 P2 和 P3 分支的 C2f 之后,这两个分支负责小目标检测,注意力带来的定位精度提升最明显。P4、P5 分支可以不加,省计算量。

3.3 损失函数和正样本分配策略的调整

YOLOv8 默认用 TaskAlignedAssigner 做正样本分配,它根据分类得分和 IoU 的加权来选正样本。小目标的问题在于 IoU 对位置偏移极其敏感——两个 8×8 的框,中心偏移 2 像素,IoU 就从 1.0 掉到 0.5 以下。这导致小目标在训练时正样本数量严重不足。

一个有效的调整是降低 TaskAlignedAssigner 里 topk 的阈值,让更多 anchor 参与正样本计算。在ultralytics/utils/tal.py里,默认topk=13,可以调到topk=20。同时把alpha和beta的权重从默认的 0.5/6.0 调整为 0.7/4.0,提高分类得分在匹配中的权重,让小目标更容易被分配到正样本。

另一个改动是损失函数里 CIoU 换成 WIoU(Wise-IoU)。WIoU 通过动态调整离群样本的梯度权重,让模型更关注普通质量的锚框,对小目标这种 IoU 普遍偏低的场景更友好。在ultralytics/utils/loss.py里替换 IoU 计算即可,核心改动是给每个锚框的 IoU 损失乘上一个动态权重系数。

4. 训练参数怎么设:航拍小目标的调参清单

4.1 输入分辨率、batch size 和显存的三角关系

航拍小目标检测里,输入分辨率是最关键的参数。640×640 对自然图像够用,但航拍小目标建议至少 1024×1024,有条件的上 1280×1280。分辨率翻倍,小目标在特征图上的像素数翻四倍,检测效果提升立竿见影。

但分辨率上去之后显存吃紧。以 yolov8n 为例,640 分辨率下 batch size 可以开到 32(8GB 显存),1024 分辨率下只能开到 8,1280 下只能开到 4。如果显存不够,优先保证分辨率,用梯度累积来补偿 batch size:

# 1024 分辨率,batch=8,梯度累积 4 步等效 batch=32 yolo detect train \ model=yolov8n-p2.yaml \ data=dataset/data.yaml \ imgsz=1024 \ batch=8 \ accumulate=4 \ epochs=200 \ device=0

accumulate参数控制梯度累积步数,等效于把 batch size 放大accumulate倍。注意学习率要按等效 batch size 来调,等效 batch 从 32 变成 8×4=32 时学习率不用改;如果等效 batch 变了,学习率按线性缩放规则调整。

4.2 学习率、优化器和 warmup 的配合

YOLOv8 默认用 SGD,lr0=0.01,lrf=0.01。航拍小目标训练我一般改成 AdamW,lr0=0.001,lrf=0.05。原因是小目标的梯度信号弱,SGD 容易在早期就陷入局部最优,AdamW 的自适应学习率能让小目标相关的参数获得更大的更新幅度。

warmup 阶段建议拉长到 5 个 epoch(默认 3),warmup_momentum=0.5,warmup_bias_lr=0.05。小目标检测头是新增的,随机初始化,需要更长的 warmup 来稳定训练。如果跳过 warmup 或者 warmup 太短,前几个 epoch 的 loss 会剧烈震荡,甚至出现 NaN。

yolo detect train \ model=yolov8n-p2.yaml \ data=dataset/data.yaml \ imgsz=1024 \ batch=8 \ accumulate=4 \ epochs=200 \ optimizer=AdamW \ lr0=0.001 \ lrf=0.05 \ warmup_epochs=5 \ warmup_momentum=0.5 \ warmup_bias_lr=0.05 \ cos_lr=True \ close_mosaic=20 \ device=0

close_mosaic=20表示最后 20 个 epoch 关闭 Mosaic 增强。Mosaic 把四张图拼成一张,对小目标检测有正则化效果,但训练末期关闭它能让模型在真实分布上收敛得更稳。cos_lr=True启用余弦退火,比默认的线性衰减更适合小目标这种需要精细收敛的任务。

4.3 数据增强的取舍:哪些该开、哪些该关

航拍小目标的数据增强和自然图像不一样。以下是我在多个项目里验证过的配置:

增强参数建议值理由
mosaic1.0(前 180 epoch)增加小目标出现频率
mixup0.0小目标混叠后更难辨认
copy_paste0.3复制小目标粘贴,直接增加正样本
degrees0.0航拍目标方向已任意,旋转无意义
translate0.1小幅平移即可
scale0.5缩放增强对小目标尺度多样性有帮助
fliplr0.5水平翻转安全
flipud0.5航拍图像上下翻转也合理
hsv_h0.015色调变化小
hsv_s0.7饱和度变化可以大
hsv_v0.4亮度变化适中

copy_paste=0.3是航拍小目标检测的杀手锏。它把小目标实例复制粘贴到图像其他位置,直接增加小目标的训练样本数。注意粘贴时要检查新位置是否与已有目标重叠,重叠度过高就跳过。mixup一定要关掉,两张图叠加后小目标变成半透明鬼影,模型学到的全是噪声。degrees也建议关掉,航拍视角下目标方向已经足够多样,再旋转只会引入不自然的边缘填充。

5. 训练和推理中常见的翻车现场排查

5.1 现象:loss 正常下降但 mAP 不涨

原因:验证集和训练集来自同一张大图的切块,数据泄漏导致验证指标虚高,但模型实际没学到泛化能力。或者类别标注顺序在data.yaml和转换脚本里不一致,模型学的是错位的类别。

解决:按原始大图分组划分数据集,确保同一张大图的所有切块只出现在一个集合里。用yolo detect val命令单独跑验证,检查每个类别的 AP,如果某个类别 AP 为 0,大概率是类别索引错位。

5.2 现象:训练到一半 loss 突然变成 NaN

原因:航拍数据里存在退化标注框(宽或高为 0),计算 IoU 时除零。或者学习率太大,AdamW 在某个 batch 上梯度爆炸。

解决:在数据转换脚本里加过滤,宽高小于 2 像素的框直接丢弃。训练时加梯度裁剪,YOLOv8 默认grad_clip=10.0,可以降到 5.0。如果已经出现 NaN,从上一个正常 checkpoint 恢复,把学习率降一半继续。

5.3 现象:推理时小目标全漏,大目标正常

原因:推理分辨率低于训练分辨率。训练用 1024,推理用 640,小目标在推理时直接消失了。或者 P2 检测头的输出在 NMS 阶段被大框抑制掉了。

解决:推理分辨率必须和训练一致。NMS 的iou_thres从默认 0.7 降到 0.5,减少小目标被大框误抑制的概率。另外检查conf_thres,默认 0.25 对小目标可能太高,降到 0.1 试试。

5.4 现象:模型在验证集上表现好,部署到无人机上效果差

原因:训练数据是晴天正午拍摄的,部署时遇到阴天、黄昏、逆光,域偏移导致性能崩塌。或者训练时用了 TTA(测试时增强),部署时没开。

解决:训练数据里加入不同光照、不同天气、不同季节的航拍图像。如果拿不到真实数据,用 HSV 增强模拟光照变化,hsv_v调到 0.6 以上。部署时如果算力允许,开 TTA 的 flip 增强,推理时间翻倍但小目标召回率能提升 3~5 个百分点。

5.5 现象:增加 P2 检测头后模型不收敛

原因:P2 分支的随机初始化权重和预训练权重差异太大,直接训练导致梯度冲突。或者 P2 分支的通道数设得太小(比如 64),特征表达能力不足。

解决:先冻结 backbone 和 neck,只训练 head 10 个 epoch,让 P2 分支先初步收敛,再解冻全模型微调。P2 分支的通道数建议不低于 128,和 P3 分支保持一致。如果还是不收敛,检查 P2 特征融合时的 Concat 操作,确保上采样后的特征图和 backbone 输出的 P2 特征图尺寸完全对齐。

6. 从训练到部署:验证改进是否真的有效

改进做完之后,怎么确认它真的有用而不是玄学?我一般用三步验证法。

第一步,消融实验。在同一个数据集上分别跑:baseline YOLOv8、+P2 检测头、+P2+CA 注意力、+P2+CA+WIoU 损失。每次只加一个改动,记录 mAP@0.5、mAP@0.5:0.95、小目标(面积<32²)的召回率、推理 FPS。下面是我在自建航拍数据集(约 12000 张切块,5 类目标)上的典型结果:

配置mAP@0.5小目标召回FPS (GTX 1660 Ti)
YOLOv8n baseline0.6120.4882
+P2 检测头0.6740.6171
+P2+CA0.6910.6468
+P2+CA+WIoU0.7030.6768

P2 检测头贡献了最大的召回率提升(+13 个百分点),CA 注意力再贡献 3 个点,WIoU 贡献 3 个点。FPS 从 82 降到 68,仍然满足实时要求(>30 FPS)。

第二步,可视化验证。用yolo detect predict在验证集上跑推理,把漏检和误检的图单独挑出来看。小目标漏检通常集中在两种场景:目标密集区域(NMS 抑制)和低对比度区域(特征响应弱)。如果 P2 检测头加了之后密集区域漏检仍然严重,考虑把 NMS 换成 Soft-NMS。

第三步,跨域验证。如果条件允许,找一个不同来源的航拍数据集做零样本测试。比如在自建数据集上训练,在 DOTA 或 VisDrone 的某个子集上直接推理。跨域 mAP 掉 10 个点以内算正常,掉 20 个点以上说明模型过拟合了训练集的拍摄条件,需要加更强的数据增强或者引入域自适应。

最后说一个我踩过的坑:改进方案不要一次全上。我曾经把 P2、CA、WIoU、copy_paste 一起加进去,结果 mAP 只涨了 2 个点,排查了一周才发现是 copy_paste 的粘贴位置和 P2 检测头的感受野冲突,粘贴的小目标大量落在 P2 分支的盲区。后来把 copy_paste 的粘贴范围限制在图像中心 60% 区域,mAP 才正常涨上去。做改进要像做实验一样,一次只动一个变量,确认有效再叠加下一个。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询