从产线检测的漏报说起

上周在工厂现场调试,产线主管指着监控屏问我:“为什么远处传送带上的小零件经常漏检?明明在近处都能识别。”我拉出验证集的统计一看,小目标(像素面积小于32×32)的召回率比常规目标低了近三十个百分点。这不仅是模型能力问题,更是数据层面的“先天不足”——训练集中小目标样本不足,且遮挡情况模拟不够真实。

在目标检测任务中,小目标和遮挡目标一直是性能提升的瓶颈。YOLO系列虽然速度快,但在处理这类目标时容易丢失细节或误判。今天我们就深入数据增强层,聊聊如何通过“数据手术”提升模型对这两类难例的感知能力。


小目标增强:不只是简单缩放

很多人一提到小目标增强,第一反应就是“把图片缩小”,让原本的小目标在缩小后的图中显得更大。这个思路没错,但太粗暴。直接全局缩小图像会导致背景信息压缩过度,且目标尺寸分布失真。我们需要的是一种更精细的调控。

复制-粘贴增强(Copy-Paste Augmentation)

这是目前业界验证有效的策略之一,思路很简单:从训练集中随机抽取一些小目标实例,粘贴到其他训练图片的合理位置上。注意,这里的关键是“合理位置”——要避免粘贴到不合理区域(比如把螺丝钉贴到天空上),同时要同步更新bbox和分割掩码(如果做分割)。

def copy_paste_small_objects(img, targets, small_obj_thresh=32):
    """
    对小目标进行复制粘贴增强
    img: 原始图像
    targets: 标注信息,格式为[N, (cls, x1, y1, x2, y2)]
    small_obj_thresh: 小目标阈值,这里指最大边长
    """
    h, w = img.shape[:2]
    small_masks = []
    new_targets = []
    
    # 找出小目标
    for t in targets:
        _, x1, y1, x2, y2 = t
        if max(x2-x1, y2-y1) < small_obj_thresh:
            # 这里踩过坑:一定要深拷贝,否则原图会被破坏
            obj_patch = img[int(y1):int(y2), int(x1):int(x2)].copy()
            mask = np.ones(obj_patch.shape[:2], dtype=np.uint8)
            small_masks.append((obj_patch, mask, (x1, y1)))
    
    # 随机选择几个小目标粘贴到新位置
    for obj_patch, mask, _ in random.sample(small_masks, k=min(3, len(small_masks))):
        # 随机生成粘贴位置,这里简单实现,实际要考虑遮挡关系和场景合理性
        new_x = random.randint(0, w - obj_patch.shape[1])
        new_y = random.randint(0, h - obj_patch.shape[0])
        
        # 混合粘贴(简单alpha混合)
        roi = img[new_y:new_y+obj_patch.shape[0], new_x:new_x+obj_patch.shape[1]]
        blended = cv2.addWeighted(roi, 0.7, obj_patch, 0.3, 0)
        img[new_y:new_y+obj_patch.shape[0], new_x:new_x+obj_patch.shape[1]] = blended
        
        # 更新标注
        new_targets.append([0, new_x, new_y, new_x+obj_patch.shape[1], new_y+obj_patch.shape[0]])
    
    return img, np.vstack([targets, new_targets]) if new_targets else targets

注意,实际部署时建议用更成熟的库(如albumentations)的实现,自己写容易漏掉边缘情况。粘贴时考虑光照一致性、阴影匹配会更逼真。

多尺度训练(Multi-Scale Training)的变体

YOLO本身支持多尺度训练,但我们可以针对小目标做调整。不是简单随机缩放整图,而是对小目标密集的图片进行差异化缩放。比如,检测到某张图小目标数量超过阈值,就以较高概率触发放大操作(比如缩放到原图的1.5倍),然后裁剪出原始尺寸的区域进行训练。这样相当于给小目标做了“特写镜头”。

# 在dataloader中的简化示例
if random.random() < 0.3 and count_small_objects(targets) > 5:
    # 小目标密集,进行放大裁剪
    scale = random.uniform(1.2, 1.8)
    new_h, new_w = int(h * scale), int(w * scale)
    img = cv2.resize(img, (new_w, new_h))
    # 随机裁剪回原尺寸
    start_x = random.randint(0, new_w - w)
    start_y = random.randint(0, new_h - h)
    img = img[start_y:start_y+h, start_x:start_x+w]
    # 同步调整bbox坐标(代码略)

遮挡目标增强:模拟真实世界的残缺

遮挡是目标检测的老大难问题。现场环境中,目标被部分遮挡是常态。训练集如果都是“完整亮相”的目标,模型遇到遮挡就懵了。

随机擦除(Random Erasing)与网格遮挡(Grid Mask)

随机擦除最早在分类任务中提出,后来在检测中也很好用。思路是在图像中随机选择一个矩形区域,用随机值或均值填充,模拟遮挡。但纯随机矩形可能不太自然,更推荐使用Grid Mask或其变种——生成网格状的遮挡模式,更接近树枝、栅栏等现实遮挡物。

def grid_mask_augment(img, prob=0.5, grid_size_range=(10, 30), ratio_range=(0.3, 0.7)):
    """
    网格遮挡增强
    grid_size_range: 网格单元大小范围
    ratio_range: 遮挡比例范围(每个单元内遮挡部分的占比)
    """
    if random.random() > prob:
        return img
    
    h, w = img.shape[:2]
    grid_size = random.randint(*grid_size_range)
    ratio = random.uniform(*ratio_range)
    
    mask = np.ones((h, w), dtype=np.uint8)
    for i in range(0, h, grid_size):
        for j in range(0, w, grid_size):
            # 每个网格单元内随机生成一个小矩形遮挡
            cell_h = min(grid_size, h - i)
            cell_w = min(grid_size, w - j)
            if cell_h <= 0 or cell_w <= 0:
                continue
            
            # 计算遮挡区域大小
            erase_h = int(cell_h * ratio)
            erase_w = int(cell_w * ratio)
            if erase_h == 0 or erase_w == 0:
                continue
            
            # 随机位置
            erase_x = j + random.randint(0, cell_w - erase_w)
            erase_y = i + random.randint(0, cell_h - erase_h)
            
            # 填充灰色或随机噪声(这里用灰色)
            img[erase_y:erase_y+erase_h, erase_x:erase_x+erase_w] = np.random.randint(100, 180, (erase_h, erase_w, 3))
    
    return img

目标间遮挡合成

更高级的做法是模拟目标之间的相互遮挡。可以从同一批数据中随机选取两个实例,计算其空间位置,将一个实例部分覆盖到另一个上,同时调整被遮挡目标的bbox。这需要更精细的像素级操作,但效果最接近真实场景。

# 概念性代码,实际实现需要考虑遮挡顺序、光照融合等
def inter_object_occlusion(img, targets):
    if len(targets) < 2:
        return img, targets
    
    # 随机选择两个不同目标
    idx1, idx2 = random.sample(range(len(targets)), 2)
    t1, t2 = targets[idx1], targets[idx2]
    
    # 计算重叠区域(这里简化,实际应计算实例分割掩码)
    x_overlap = max(0, min(t1[3], t2[3]) - max(t1[1], t2[1]))
    y_overlap = max(0, min(t1[4], t2[4]) - max(t1[2], t2[2]))
    
    if x_overlap > 0 and y_overlap > 0:
        # 模拟t2遮挡t1的部分区域
        overlap_area = img[int(max(t1[2], t2[2])):int(min(t1[4], t2[4])),
                           int(max(t1[1], t2[1])):int(min(t1[3], t2[3]))]
        # 用t2区域的颜色或纹理填充(这里简单用噪声)
        overlap_area[:] = np.random.randint(0, 255, overlap_area.shape)
    
    # 注意:这里bbox没有调整,实际应该根据遮挡程度调整t1的bbox或添加难度权重
    return img, targets

融合策略与调参经验

单独使用某一种增强可能效果有限,甚至带来副作用。我的经验是:

小目标增强容易导致假阳性增多,因为背景区域被误认为小目标的概率上升。解决办法是同时加强负样本挖掘,或者在损失函数中调整小目标的权重。YOLOv11的anchor设计本身对小目标不太友好,建议在数据增强后重新聚类anchor,特别是增加小尺度anchor的数量。

遮挡增强过度会破坏目标可识别性,模型可能学不到完整特征。建议控制遮挡比例在20%~40%之间,并且优先遮挡目标边缘区域而非中心关键特征区域(比如人脸的眼睛、车辆的轮胎)。可以结合关键点检测先验知识,避免遮挡重要语义部位。

增强不是越多越好。我通常采用渐进式策略:先在小规模数据上测试各种增强组合,观察验证集mAP的变化曲线。如果小目标AP提升但大目标AP下降,说明增强强度需要调整。最终线上部署时,我会保留23种最有效的增强,并控制其触发概率在0.30.5之间。


部署前的最后检查

数据增强在训练时是“虚拟”的,但部署环境是真实的。有个坑我踩过:增强时模拟的遮挡模式(如整齐网格)和真实场景(不规则树枝)差异太大,导致模型过拟合到增强模式。建议:

  1. 去现场拍一些真实遮挡、小目标的负样本,混入训练集。
  2. 增强参数(如遮挡形状、大小)尽量随机化,避免模式固定。
  3. 在验证集上,单独统计小目标和遮挡目标的精度,而不是只看整体mAP。

最后说个反直觉的点:有时候数据增强解决不了的问题,恰恰是模型架构的瓶颈。如果尝试了各种增强技巧后小目标AP仍然上不去,可能需要回头审视backbone的下采样率是否过高,或者检测头是否缺乏细粒度特征融合。数据增强是“喂好数据”,但模型也得“消化得了”才行。


写在后面

调试数据增强像老中医开方子,得根据模型的具体“体质”调整。我的习惯是每轮训练后,可视化增强后的样本和模型预测结果,特别关注那些难例——看看是数据没喂到位,还是模型学偏了。记住,增强的目标不是让训练集变得“花里胡哨”,而是让模型提前见识它在战场上会遇到的所有挑战。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐