1.1 一个让我折腾了整整两周的“怪现象”
先说一下这个怪现象长什么样。我做的这个低空俯拍场景,画面里人、车、非机动车数量很多,但绝大多数目标在640x640的输入下只有15到25个像素。训练时Loss一路往下掉,看起来模型学得很努力,可是验证集上一算指标,AP_all刚过30%,AP_small更是常年挂在13%左右。我把anchor重新聚类过,把NMS阈值来回调过,甚至把损失函数里小目标的权重加大过,效果不能说完全没有,但顶多是从13.2变成13.7,属于那种“调了半天图个心理安慰”的提升。
后来我做了一个很土的决定:把所有训练样本按网格拼在一起,当成一张大图喂给模型。我给它起名叫OpenMontage。这个思路并不是我拍脑袋发明的,YOLOv4时代就有人用Mosaic把4张图拼成一张来增强小目标训练,我的改动只是把“4张图随机拼接”扩展成了3x3、4x4甚至5x5的规则网格,并且在此基础上补了边界框过滤、可见面积筛选、亮度扰动这几个关键步骤。整套代码我整理过,也在YOLOv8和几个CenterNet类框架上跑通过。
先把结论放在前面:在同样数据、同样训练配置下,单单换掉数据增强这一段,我的AP_small从13.7%提到了19.6%,提升了差不多6个点。对于我这种“经常折腾一星期只涨一个点”的人来说,这个收益已经算非常夸张了。
所以这篇东西我不想讲什么高深理论,就老老实实说说:小目标漏检到底缺的是什么,OpenMontage这个网格拼接增强是怎么实现的,以及我在真实训练里踩过的那些坑。如果你想复现,第3章的代码可以直接抄走改一改,第5章则是我觉得比代码更值钱的工程经验。
1.2 小目标在特征图上的真实处境
先抛开增强技巧,回到问题的根上:小目标为什么难检测?大多数人第一时间会说是“分辨率不够”或者“模型感受野太大”。这话对了一半,但不够准确。
以YOLOv8s为例,它有3个检测头,分别对应8倍、16倍、32倍下采样特征图。一个16x16像素的目标,在32倍下采样图上只有0.5个像素,这意味着它根本不可能被大尺度特征图感知到;在16倍下采样图上勉强有1x1个像素,几乎没有形状信息;真正能稍微看清它轮廓的,是8倍下采样特征图,但8倍的特征图本身感受野偏小,又缺乏足够的上下文背景。
问题还不止于此。在常规训练流程里,小目标在整张图里的像素占比极低。比如一张1280x720的俯拍图里有个20像素高的人,他占整张图面积的不到0.04%。模型在训练时看到的大部分梯度都来自于占比更大的背景和中等目标,小目标虽然也在Loss里贡献了一部分数值,但它的梯度信号被淹没在大量“大目标”的贡献里。
这就是为什么很多人在改Loss、改Anchor权重的时候感觉“有力使不出”:不是模型不愿意学小目标,而是它在训练过程中收到的有效信号太少、太稀疏了。你就算把Loss函数里小目标的权重调得再高,它看到的依然是“一整张图里一个小点”,这个“小点”周边的背景信息太多、目标信息太少,模型的注意力很难稳定地被吸引到那个点上。
1.3 常规手段为什么总是差一口气
我试过几种常见的改进方案,效果都不尽如人意,简单复盘一下:
第一种是多尺度训练。把输入随机从640切到960甚至1280,让目标在更大分辨率下变得更大。这个做法有效,但问题是它只解决了“目标在特征图上的尺寸”问题,没有解决“目标在训练样本中出现的频次”问题。大分辨率训练让显存压力明显上升,而且很多小目标的绝对尺寸虽然变大了,但在图里的相对占比并没有本质变化。
第二种是切图训练,也就是把大图切成若干小块,每个小块单独送进模型。这种方法确实能让小目标“变大”,但代价是大量上下文信息被切没了。我试过把1280的图切成4块640的小图训练,结果AP_small确实涨了,但是中大型目标的AP掉了不少,因为模型见不到“完整场景”,它对目标之间相对关系的理解变差了。
第三种是增大Anchor数量或者调小Anchor尺寸。YOLO系列本身对Anchor的依赖已经不强了,尤其是Anchor-Free的检测头出来后,这个方法的边际收益越来越低。
说到底,这些方案都在绕着一个核心问题打转:如何在不破坏上下文的前提下,让小目标在训练阶段被高频次、高强度地感知到。我的经验是,数据层面能解决的事,就不要总去折腾模型结构和Loss。OpenMontage就是从这个角度切入的。
2. OpenMontage的做法:从Mosaic到规则网格
2.1 Mosaic到底好在哪,又有哪里不够
Mosaic增强当年在YOLOv4里被重点推荐,核心操作是随机选4张图,通过一个中心点把它们缩放到同一张画布上。每张图只占据画布的四分之一左右,相当于把每张原始图片的整体尺度压缩了,这样原本在图中不显眼的物体,在整张训练图里仍然不显眼,但训练过程中随机采样的组合让模型每张图都能看到“多图混合的大场景”。
Mosaic的好处是突出的小目标训练样本数量明显增多,因为4张图都缩小到原图的50%左右,小目标对应的像素比例还是一样的,但模型需要在更高频次的训练数据里处理多个小目标;另一个附带好处是背景混合,能降低模型对特定背景的记忆。
但它也有明显短板。首先是随机中心点容易产生强烈的拼接边界感,拼接缝附近的目标经常被随机切割成两半,训练信号不稳定;其次只有4张图,尺度变化仍然不够丰富。
2.2 规则网格为什么对小目标更友好
OpenMontage做了两件改变:
第一,把拼接方式从“随机中心点”改成“规则网格”。3x3网格确保每张训练图里都有9张原图的缩放版本,4x4网格则有16张。模型在同样一次迭代里看到的图像多样性大幅提升,而且采样次数多了之后,小目标出现在训练数据里的频次增加的远比Mosaic明显。
第二,我给每个网格单元单独做了一次随机缩放和随机翻转。注意这里的缩放不是把图片直接拉伸成固定cell大小,而是保持宽高比缩放后再居中放置到单元格里。拉伸虽然简单,但会把物体的形状比例搞坏,尤其是行人和车辆这类对纵横比敏感的目标,训练出来回归框很容易变形。
用网格拼接还有一个很微妙的好处:当模型面对这些网格图时,它必须学会在“更小的目标尺寸”下工作。你可以把它理解成一种强制性的尺度压力测试。模型每天都在看这种“全世界都变小了”的画面,它对小目标的响应自然变得更敏感。
2.3 它和切片、Copy-paste这些方法的关系
很多朋友问我,这不就是切图吗?不是的,切图是把大图切割成小块保持目标尺寸不变,网格拼接是把多张小图缩小后拼成一张大图。两者方向正好相反。切图涨小目标但丢上下文,网格拼接保留了完整场景但改变了尺度分布。OpenMontage本质上是一种“尺度增强”而不是“裁剪策略”。
Copy-paste则是把某个目标从一张图里抠出来贴到另一张图上,对稀有类别的目标很管用,但它没办法让模型学会“目标变小之后依然是小目标”这种尺度连续性。Copy-paste适合解决“类别不平衡”,OpenMontage更适合解决“尺度不平衡”。这两个可以叠加使用,但我个人建议先分别消融,别一上来就全上一锅炖。
3. 从零手写一个可跑的OpenMontage增强
3.1 数据流设计
在给代码之前,先把你脑子里那张流程图理顺。整个增强的输入是若干张原始图片和它们的YOLO格式标注文件,输出是一张拼接后的画布图以及对应画布坐标系下的归一化标注。具体流程如下:
- 随机选N张图,N等于网格数的平方(3x3就是9张,4x4就是16张)。
- 把每张图随机做水平翻转,随即做轻微的亮度、饱和度扰动。
- 按网格单元的尺寸计算缩放比例,保持宽高比缩放图片。
- 把缩放后的图放到对应cell的正中心,多出来的空间用灰色填充。
- 对每张图的边界框做同样的缩放和平移变换,然后把超出画布范围的边界裁剪掉,裁剪后可见面积太小就直接丢弃。
- 把裁剪后的边界框从xyxy格式转成归一化的cxcywh格式,供YOLO系模型读取。
这里面最容易出错的是第5步。很多人做Mosaic增强时直接把边界框clip一下就了事,结果出现大量只剩一个边角的残缺框,模型学到一堆错误标注。我最后用的策略是计算“可见面积比”:如果裁剪后可见面积不足原始面积的25%,这个框直接舍掉;如果可见面积超过25%,保留裁剪后的框,并且要求裁剪后框的宽高都不小于3个像素。
3.2 核心代码
下面是整个增强最核心的代码部分,我做了精简,只保留最关键的处理逻辑,方便你直接抄到自己的工程里改:
import os import random import numpy as np import cv2 def load_image_and_boxes(img_path): """读取图片和YOLO格式标注,返回xyxy格式的boxes""" img = cv2.imread(img_path) if img is None: return None, None h, w = img.shape[:2] boxes = [] label_path = img_path.replace("/images/", "/labels/")[:-4] + ".txt" if os.path.exists(label_path): for line in open(label_path, "r"): line = line.strip().split() if not line: continue cls, cx, cy, bw, bh = map(float, line) x1 = (cx - bw / 2) * w y1 = (cy - bh / 2) * h x2 = (cx + bw / 2) * w y2 = (cy + bh / 2) * h boxes.append([int(cls), x1, y1, x2, y2]) if not boxes: return img, np.zeros((0, 5), dtype=np.float64) return img, np.array(boxes, dtype=np.float64) def clip_boxes(boxes, max_w, max_h, min_vis=0.25, min_pix=3): """裁剪边界框,并用可见面积比过滤残缺框""" keep = [] for b in boxes: cls, x1, y1, x2, y2 = b orig_area = max(1e-6, (x2 - x1) * (y2 - y1)) nx1 = max(0, min(x1, max_w - 1)) ny1 = max(0, min(y1, max_h - 1)) nx2 = max(0, min(x2, max_w - 1)) ny2 = max(0, min(y2, max_h - 1)) vis = ((nx2 - nx1) * (ny2 - ny1)) / orig_area if vis >= min_vis and (nx2 - nx1) >= min_pix and (ny2 - ny1) >= min_pix: keep.append([cls, nx1, ny1, nx2, ny2]) return np.array(keep, dtype=np.float64) if keep else np.zeros((0, 5), dtype=np.float64) def make_montage(img_paths, grid=3, canvas=(960, 960), min_vis=0.25): """ 核心:网格拼接增强 img_paths: 图片路径列表 grid: 网格边长,3表示3x3 canvas: 输出画布大小(height, width) """ canvas_h, canvas_w = canvas cell_h = canvas_h // grid cell_w = canvas_w // grid n_cells = grid * grid out_img = np.full((canvas_h, canvas_w, 3), 114, dtype=np.uint8) all_boxes = [] # 如果图片数量不足,允许重复采样 chosen = np.random.choice(img_paths, n_cells, replace=len(img_paths) < n_cells) for idx, path in enumerate(chosen): img, boxes = load_image_and_boxes(path) if img is None: continue # 随机水平翻转,注意边界框的x坐标也要跟着翻 if random.random() < 0.5: h_img, w_img = img.shape[:2] img = img[:, ::-1].copy() if len(boxes) > 0: boxes[:, [1, 3]] = w_img - boxes[:, [3, 1]] # 保持宽高比缩放图片 h_img, w_img = img.shape[:2] scale = min(cell_w / w_img, cell_h / h_img) new_w = max(1, int(round(w_img * scale))) new_h = max(1, int(round(h_img * scale))) img = cv2.resize(img, (new_w, new_h), interpolation=cv2.INTER_LINEAR) if len(boxes) > 0: boxes[:, [1, 3]] *= scale boxes[:, [2, 4]] *= scale # 放置到单元格中心,灰色背景填补空白 cell_x = (idx % grid) * cell_w cell_y = (idx // grid) * cell_h dx = cell_x + (cell_w - new_w) // 2 dy = cell_y + (cell_h - new_h) // 2 out_img[dy:dy + new_h, dx:dx + new_w] = img if len(boxes) > 0: boxes[:, [1, 3]] += dx boxes[:, [2, 4]] += dy all_boxes.append(boxes) if not all_boxes: return out_img, np.zeros((0, 5), dtype=np.float64) all_boxes = np.concatenate(all_boxes, axis=0) all_boxes = clip_boxes(all_boxes, canvas_w, canvas_h, min_vis) # xyxy转归一化cxcywh labels = np.zeros((len(all_boxes), 5), dtype=np.float64) labels[:, 0] = all_boxes[:, 0] labels[:, 1] = ((all_boxes[:, 1] + all_boxes[:, 3]) / 2) / canvas_w labels[:, 2] = ((all_boxes[:, 2] + all_boxes[:, 4]) / 2) / canvas_h labels[:, 3] = (all_boxes[:, 3] - all_boxes[:, 1]) / canvas_w labels[:, 4] = (all_boxes[:, 4] - all_boxes[:, 2]) / canvas_h return out_img, labels几点说明:
replace=len(img_paths) < n_cells这个参数很关键。数据集小的时候允许同一张图被重复采样,否则会经常因为凑不齐9张图而报错。- 灰度值114是参考YOLO的padding设置,模型在训练时就习惯这种灰边,不是随便选的。
- 如果你用的是COCO类别的模型,建议保留背景padding颜色为114;如果是自己训练的域内模型,也可以改成128甚至0,这个没有绝对标准,但千万别每个cell的padding颜色随机变化,会让模型把“灰边”当成干扰特征。
3.3 在YOLOv8训练流程中接入
接入方式有很多种,最简单的是改训练数据加载器。以Ultralytics YOLOv8为例,你可以在BaseDataset.__getitem__里引入概率判断,每次迭代有40%的概率调用make_montage,其余60%走常规Mosaic增强。大致思路如下:
class MontageDataset(torch.utils.data.Dataset): def __init__(self, img_paths, prob=0.4, grid=3): self.img_paths = img_paths self.prob = prob self.grid = grid def __len__(self): return len(self.img_paths) * 50 # 适当放大训练步数 def __getitem__(self, _): if random.random() < self.prob: batch_paths = np.random.choice(self.img_paths, self.grid * self.grid) img, labels = make_montage(batch_paths, grid=self.grid) else: img, labels = load_normal_sample(self.img_paths) img = torch.from_numpy(img.transpose(2, 0, 1)).float() / 255.0 return img, labels注意一个细节:__len__不能只是图片总数,因为每次__getitem__都会吞掉好几张原始图片。如果还是返回图片总数,一个epoch结束后会有大量图片根本没见过。我建议把长度设为原始图片数的3到5倍,具体多少看你训练预算,让每个epoch的实际采样覆盖更充分。
4. 实测:低空俯拍小目标数据上的改变
4.1 实验设置
为了验证这个增强到底有多大效果,我在自己标注的低空俯拍数据集上做了几组对比实验。数据集一共2000张图,手工标注了约9000个目标实例,类别包括行人、小汽车、非机动车、公交车等5类。其中尺寸小于32x32像素的目标占比约70%,属于典型的“小目标密集”场景。
训练配置统一为:
| 配置项 | 数值 |
|---|---|
| 模型 | YOLOv8s |
| 输入分辨率 | 640x640 |
| Batch Size | 16 |
| Epoch | 50 |
| 优化器 | SGD,momentum 0.937 |
| 初始学习率 | 0.01,余弦退火 |
| 数据增强 | Baseline一组 / Mosaic一组 / OpenMontage一组 |
OpenMontage组的画布设置是960x960,网格数分别测试了3x3、4x4和5x5,概率固定为40%。其余训练参数一概不动,确保对比的是增强本身的贡献。
4.2 指标对比结果
| 增强方案 | AP_small@0.5 | AP_all@0.5 | 备注 |
|---|---|---|---|
| Baseline(无Mosaic类增强) | 13.7 | 35.2 | 常规翻转、裁剪、HSV扰动 |
| Mosaic(4图随机拼接) | 14.9 | 35.8 | 经典YOLOv4增强 |
| OpenMontage 3x3 | 18.1 | 36.4 | 9个cell |
| OpenMontage 4x4 | 19.6 | 36.9 | 16个cell |
| OpenMontage 5x5 | 18.2 | 36.0 | 25个cell,过度缩小 |
这个结果让我挺意外的,因为一开始我以为Mosaic和OpenMontage的差距不会很大,毕竟都是多图拼接。但实际跑下来,4x4网格比Mosaic在AP_small上高了接近5个点。原因我猜是这样:Mosaic只有4张图,其中两三张图的缩放尺度依然接近原始尺寸,小目标的“小尺度信号”还是不够密集;而16张图的网格把所有目标都压缩到原始尺寸的1/4左右,模型在训练时面对的小目标数量呈指数级增加。
5x5网格反而下降,这个也符合直觉:图片被缩得太小,很多目标已经小于3像素,连标注框都过不了过滤阈值,已经进入了“肉眼都看不清”的阶段,模型再怎么学也学不出有意义的信息。
4.3 从消融中得到的用法建议
我还顺带做了一个概率消融,发现OpenMontage的比例在40%左右效果最好。如果全程100%使用OpenMontage,模型AP_all反而降到34.6,比Baseline还低。原因很简单,模型每天都只看缩小后的画面,从来没机会在正常的尺度分布下做预测,到了验证集,面对真实的原始尺度时反而迷糊了。
如果你也在自己的数据集上试这个方案,我建议按下面这套参数起步:
- 先用3x3网格,概率20%,跑一个短实验,看看AP_small有没有上升趋势。
- 如果趋势是正面的,再调大概率到40%,同时把网格数升到4x4。
- 如果AP_all出现明显下降,说明你的场景中“上下文关联”比“目标尺度”更敏感,把概率降回20%。
- 画布尺寸建议设成训练输入分辨率的1.5倍左右,比如训练输入是640,画布就是960。画布太大容易造成训练和推理时输入分辨率差距过大,模型过拟合了缩小的画面。
5. 踩过的坑与工程化建议
5.1 边界框被网格切掉之后,光clip是不够的
这个坑我印象最深。第一版代码里我对边界框就是简单地np.clip,把超出画布范围的坐标硬截断到边界内。训练几天后发现中间的AP曲线看起来还行,但最终验证时小目标的位置偏差很大。后来我单独把一批增强后的图片和标注画出来,发现问题一目了然:很多被截断后的边界框只剩一个细长条,比如原本一个人有20x40像素,裁剪后变成3x8像素,这种标注在YOLO看来就是一个不完整的怪异目标,模型学出来的预测框自然都是一条条细线。
解决办法就是我上面代码里写到的“可见面积比”过滤。实际操作中还有个小细节:可见面积比设太大了也不行。设成0.5的时候,很多中心点在格子边缘的框会被全部丢弃,导致某些cell里几乎没有可用目标,训练信号会变得不稳定。我自己反复调下来,0.25到0.35之间比较合适。
5.2 网格cell之间亮度不一致导致特征混乱
多图拼接必然会出现一个问题:9张图的亮度、色调可能各不相同。如果一张训练图里白白亮亮的cell旁边全是阴天暗调的cell,模型可能会把“亮度不一致”错误地关联到类别判断上。我在第二版代码里给每张原图加了亮度抖动,但要注意这个抖动是加在拼接之前的,也就是说每张图独立随机调整亮度,这样模型不会学到“同一张图里亮度一致,于是亮度成为有效特征”的错误线索。
具体做法很简单,把原始图片转HSV空间,V通道乘以一个0.8到1.2的随机系数,再转回BGR。这一小步不涨AP,但能明显提升模型的稳定性,尤其是验证集上模型对光照变化的鲁棒性会好很多。
5.3 显存不够时,别直接降低分辨率
OpenMontage会把训练输入从640变成960甚至1280,显存占用自然大涨。我试过直接把画布降到640,结果网格做成了3x3但每个cell只有213像素,网络缩得太狠,切割效果直接崩坏。正确做法是保持画布分辨率不变,降低Batch Size,再用梯度累积去模拟原来的Batch Size。
梯度累积的代码很简单:
# 假设原计划batch_size=16,显存只够跑4 accum_steps = 4 for i, (imgs, labels) in enumerate(loader): loss = model(imgs, labels) loss = loss / accum_steps loss.backward() if (i + 1) % accum_steps == 0: optimizer.step() optimizer.zero_grad()实际测试下来,batch=4加梯度累积4步的效果跟batch=16几乎没区别,但训练时间会多出10%到15%,这是没办法的事。
5.4 什么场景不要用OpenMontage
最后必须泼一盆冷水:OpenMontage不是万金油。我自己用过几次之后,总结了几条“不适合”的场景。
第一,数据集中目标本身就不小,比如工业质检里检测产品表面的大面积缺陷,AP_small网红标准几乎没有提升空间,这时候用OpenMontage只会增加训练成本。第二,单张图目标非常稀疏,可能整张图就一两个目标,拼接后一个cell里经常是空的,模型看到的更多是“空背景+少量目标”的组合,反而更不利于注意力集中。第三,训练数据量本身就很小(低于500张图)时,反复采样同一批图容易过拟合,我建议这种情况先靠基础的旋转、翻转让数据集多样化,别急着上网格拼接。
判断一个增强适不适合你的场景,最直接的办法是画一组增强后的样本图,截图出来自己看:如果你自己都很难在一张训练图里快速数清目标的位置,那模型也很难,这时候要么调参数,要么干脆放弃这个增强方案。
最后再分享一个小经验:我刚跑通OpenMontage的时候,一度怀疑是不是所有小目标问题都能靠它解决,后来换了两个不同场景的数据集才发现,提升幅度完全不一样。增强方法要跟着数据分布走,同一个配置换个场景就直接照搬是大忌。最好的状态是把自己手里常用的增强写成模块,每个新项目先花一个上午跑三组对比实验,再决定用哪套方案。这比死活抱住一个亮点技术要靠谱得多。