YOLO小样本数据集扩充实战:从信号级增强到Mosaic
简介面向YOLO目标检测小样本训练场景这份资源给出了一套轻量的数据集扩充实现方案适合刚接触数据增强或希望缓解过拟合的开发者。压缩包共2个文件包含1份md说明与1个py脚本整体仅5KB其中augmentation.py提供几何变换、色彩变换、噪声注入、关键点与边界框联动变换等增强逻辑README.md则对操作方式与参数调整加以说明。已有11956人学习下载说明该方法在社区中有较高关注度。通过阅读这份资源可以快速理解小样本图像数据扩充的核心思路并直接将脚本集成到YOLO训练流程中从而提升模型在不同光照、角度和遮挡条件下的泛化能力。整体短小精悍适合作为入门参考或工程改造基础。1. 小样本YOLO训练的瓶颈扩充不只是把图P成另一张图刚接触YOLO目标检测的人最容易掉进的一个坑就是标注了几百张图丢进模型里训练发现mAP怎么也上不去。模型在训练集上表现很好但一到验证集就全线崩溃。这不是模型结构的问题而是数据集太小模型把背景噪声和一些无关特征一起背了下来。这就是小样本图像数据集扩充要解决的问题。小样本扩充的本质不是简单地把图片左右翻转、调个亮度而是让模型在有限的标注样本里看到足够多的上下文变化。YOLO训练自己的数据集如果只有几十张到几百张标注图扩充直接决定了模型是过拟合还是真的学到了目标特征。适合的人群也很明确手里有标注数据但数量不够、想自己训YOLO模型、又不想花大价钱重新标注的人。这篇笔记就沿着信号级增强、实例级增强、在线增强这条路线把每个方法怎么落到代码、参数怎么设、坑在哪讲清楚。2. 信号级图像扩充像素变换与标签坐标同步重算2.1 小样本为什么先做信号级扩充先看loss曲线再说判断数据集是否小到需要扩充不用看任何论文指标直接看训练时的loss曲线就够了。训练集的loss一直在降验证集的loss降到某个点后开始反弹两者之间的距离越拉越大这是过拟合最典型的信号。另外一个特征更直观训练集上的mAP很高验证集上的mAP明显低一截而且随着训练轮数增加差距不变小。信号级扩充也叫像素级增强是对整张图片做颜色、几何、模糊等变换。它之所以放在第一步是因为实现成本最低、对标注数据的影响最容易控制。像亮度调整、对比度拉伸、饱和度变化这类操作不改变目标的位置和大小标签完全不用动。像翻转、旋转、裁剪这类几何变换虽然要同步改标注坐标但变换规则是确定的用公式就能算。常见做法是把这些变换组合在一起形成一套增强管道。我个人比较推荐先用信号级扩充把数据量翻到原来的4到6倍观察loss差距是否缩小。如果缩小了说明模型确实缺数据多样性再往实例级扩充走如果没变化问题可能出在标注质量或学习率上盲目扩数据只会浪费时间。增强操作是否改标签推荐参数范围适用场景水平翻转改xc坐标概率50%左右对称的目标如车辆、行人上下翻转改yc坐标概率10%以下卫星图、俯视图不适合普通地面视角亮度调整不改系数0.6~1.4光照变化大的场景对比度调整不改系数0.5~1.5阴天、逆光图片HSV色域偏移不改H±10, S±20, V±20颜色是重要特征的目标如交通标志随机裁剪改全部坐标裁剪比例70%~95%目标过大的场景2.2 用Python写一个YOLO格式兼容的扩充脚本YOLO的标签格式是每个目标一行内容为类别 x_center y_center width height其中中心点坐标和宽高都是相对于图片宽高的归一化值。用labelimg打标完YOLO格式的标注文件后直接读取txt就能拿到这些值。关键点是无论做什么几何变换都要保证最终写回的坐标仍然是归一化的且宽高始终为正数。import cv2 import numpy as np import os from pathlib import Path def read_yolo_label(label_path): 读取YOLO格式的txt标签返回 [[cls, xc, yc, w, h], ...] boxes [] with open(label_path, r) as f: for line in f: parts line.strip().split() if len(parts) 5: cls, xc, yc, w, h parts boxes.append([int(cls), float(xc), float(yc), float(w), float(h)]) return boxes def write_yolo_label(label_path, boxes): 把box列表写回YOLO格式txt with open(label_path, w) as f: for cls, xc, yc, w, h in boxes: f.write(f{cls} {xc:.6f} {yc:.6f} {w:.6f} {h:.6f}\n) def horizontal_flip(img, boxes): 水平翻转只改xc坐标w不变 h, w img.shape[:2] img_flip cv2.flip(img, 1) new_boxes [] for cls, xc, yc, bw, bh in boxes: new_boxes.append([cls, 1.0 - xc, yc, bw, bh]) return img_flip, new_boxes def adjust_brightness_contrast(img, alpha1.0, beta0): alpha控制对比度beta控制亮度不改标签 return cv2.convertScaleAbs(img, alphaalpha, betabeta) def random_crop(img, boxes, crop_ratio0.8): 随机裁剪并重算标签越界的部分直接丢弃 h, w img.shape[:2] ch, cw int(h * crop_ratio), int(w * crop_ratio) x0 np.random.randint(0, w - cw 1) y0 np.random.randint(0, h - ch 1) img_crop img[y0:y0ch, x0:x0cw] new_boxes [] for cls, xc, yc, bw, bh in boxes: x1 (xc - bw / 2) * w y1 (yc - bh / 2) * h x2 (xc bw / 2) * w y2 (yc bh / 2) * h # 计算裁剪区域内的实际坐标 nx1 max(x1, x0) ny1 max(y1, y0) nx2 min(x2, x0 cw) ny2 min(y2, y0 ch) if nx1 nx2 or ny1 ny2: continue # 目标被完全裁掉跳过 new_boxes.append([ cls, ((nx1 nx2) / 2 - x0) / cw, ((ny1 ny2) / 2 - y0) / ch, (nx2 - nx1) / cw, (ny2 - ny1) / ch ]) return img_crop, new_boxes # 使用示例 img cv2.imread(image.jpg) boxes read_yolo_label(image.txt) img_aug, boxes_aug horizontal_flip(img, boxes) img_aug adjust_brightness_contrast(img_aug, alpha1.2, beta10) write_yolo_label(image_aug.txt, boxes_aug) cv2.imwrite(image_aug.jpg, img_aug)这段脚本里最需要注意的细节在random_crop函数中。裁剪后坐标系的原点发生了变化原来归一化的坐标必须还原成像素坐标再减去裁剪起点(x0, y0)最后除以裁剪后的宽高重新归一化。如果目标只有一部分落在裁剪区域内只保留交叠部分重新计算中心点和宽高。这里有个实际渲染时容易忽略的点目标如果被裁剪到只剩几个像素模型的标注质量会下降建议设置一个最小面积阈值小于比如16×16像素的直接丢弃。水平翻转很简单但很多人会踩一个坑翻转后只改了xc忘了w是绝对值不变化。YOLO格式里w表示目标宽度在图片宽度中的比例翻转不会改变目标本身的宽高所以w、h保持原样只有中心点的归一化x坐标变成1 - xc。这个逻辑同样适用于上下翻转只是把xc换成yc。2.3 生成增强后文件名的匹配规则YOLO训练时需要图片和txt标签文件保持一致的文件名。扩充处理后如果直接把image.jpg改名成image_flip.jpg对应的txt也要同步改名成image_flip.txt。这个改名逻辑推荐在脚本里集中做不要手动改不然图多了之后文件对不上训练时会疯狂报找不到标签的错。代码中已经包含完整逻辑先读原图、读标签、做变换、写新文件。实际跑扩充时建议把原图和增强图放在同一个目录用后缀区分。数据量翻倍后在训练配置里把train.txt的内容重新生成一遍就好不需要额外做特殊配置。3. 实例级扩充把标注好的目标复制到新背景里3.1 为什么实例级扩充对小目标更有效信号级扩充做的是全局变换目标本身的像素内容基本不变变的只是光照、位置和翻转方向。遇到小目标检测这种特殊场景信号级扩充的效果非常有限。小目标的问题在于目标在图片里占的像素太少上下文信息不够模型很难把目标从背景里分离出来。比如检测远处的人整个人可能只有30×60像素模型能用的特征极其有限。实例级扩充的做法是从已有的标注图中把目标区域抠出来贴到其他图片或者纯色背景上同时生成对应的标注文件。这样做的价值在于两个方面。第一样本数量可以成倍增加并且可以控制目标出现的位置、尺度、角度。第二通过把同一个目标贴到完全不同的背景中模型被迫去学习目标本身的特征而不是把背景当成目标的一部分。这个方法的效率上限也很明显贴出来的图背景与目标的组合是人工合成的如果贴上来的目标边缘有残影或者颜色明显不协调就会产生分布偏移。分布偏移严重的增强数据会让验证集上的指标更差而不是更好。后面避坑章节会展开讲这个问题。3.2 复制粘贴扩充与标签合并的实现复制粘贴的核心是从源图中截取ROI区域resize到目标大小抠掉背景后贴到目标位置然后把坐标换算成YOLO归一化格式。背景分离最简单的方案是用阈值分割或者直接用矩形ROI标注框是矩形直接截取矩形区域贴过去背景里的干扰像素也会一起带过去。为了减少干扰可以在截取ROI前对背景区域做模糊处理只保留目标附近的清晰像素。import cv2 import numpy as np def extract_instance(img, label, expand_ratio0.2): 从标注图里抠出目标实例返回目标像素图 h, w img.shape[:2] cls, xc, yc, bw, bh label x1 int((xc - bw / 2) * w) y1 int((yc - bh / 2) * h) x2 int((xc bw / 2) * w) y2 int((yc bh / 2) * h) # 适当向外扩一点把目标边缘一起包含进来 pad_x int(bw * w * expand_ratio) pad_y int(bh * h * expand_ratio) x1 max(0, x1 - pad_x) y1 max(0, y1 - pad_y) x2 min(w, x2 pad_x) y2 min(h, y2 pad_y) return img[y1:y2, x1:x2].copy(), cls def paste_instance(bg_img, instance, cls, target_sizeNone, max_retries20): 把实例随机贴到背景图中的新位置返回新的label避免重叠 bg_h, bg_w bg_img.shape[:2] inst_h, inst_w instance.shape[:2] if target_size: inst_h, inst_w target_size instance cv2.resize(instance, (inst_w, inst_h)) else: # 限制实例最大不超过背景短边的40% max_size int(min(bg_h, bg_w) * 0.4) if inst_h max_size: scale max_size / inst_h inst_h int(inst_h * scale) inst_w int(inst_w * scale) instance cv2.resize(instance, (inst_w, inst_h)) for _ in range(max_retries): x0 np.random.randint(0, bg_w - inst_w) if bg_w inst_w else 0 y0 np.random.randint(0, bg_h - inst_h) if bg_h inst_h else 0 # 判断是否与已有标注重叠这里简化为不重叠 roi bg_img[y0:y0inst_h, x0:x0inst_w] # 简单判断ROI区域的平均亮度与周围差异过大说明可能压在了其他目标上 if roi.mean() 5: break else: return bg_img, None # 用高斯模糊处理边缘降低粘贴的突兀感 mask np.zeros_like(instance) mask[:] instance edge 3 mask_edge cv2.GaussianBlur(mask, (0, 0), edge) bg_img[y0:y0inst_h, x0:x0inst_w] roi * 0.3 mask_edge * 0.7 new_label [ cls, (x0 inst_w / 2) / bg_w, (y0 inst_h / 2) / bg_h, inst_w / bg_w, inst_h / bg_h ] return bg_img, new_label # 使用示例 src_img cv2.imread(src.jpg) src_label read_yolo_label(src.txt)[0] instance, cls extract_instance(src_img, src_label) bg_img cv2.imread(bg.jpg) bg_label read_yolo_label(bg.txt) bg_img, new_box paste_instance(bg_img, instance, cls, target_size(50, 50)) if new_box: bg_label.append(new_box) write_yolo_label(bg_aug.txt, bg_label) cv2.imwrite(bg_aug.jpg, bg_img)代码里paste_instance里有一个简化的重叠判断通过ROI区域的平均亮度来衡量。这个方法不够严谨实际落地时更稳妥的做法是在背景图上维护一个已有的框列表新放置的框与已有框计算IoUIoU大于0.3就重新选位置。把旋转、缩放的逻辑加进去也是一样的原理。核心参数有三个expand_ratio控制截取时的扩展范围0.1到0.3比较合适max_retries控制放置冲突时最多尝试多少次设置20次可以避免死循环edge控制边缘融合的模糊强度值太大会让目标变虚建议3到5。背景图的选择直接决定扩充效果。做行人检测就用没有人但结构复杂的街道图做工业缺陷检测就用干净的工件表面图。背景里不能出现同类目标否则标注会乱掉。一个常见的做法是把训练集里所有不包含某类目标的图片抽出来当作背景池轮换使用。4. 训练时的在线扩充Mosaic与数据增强开关怎么配合4.1 在线增强和离线扩充的本质差别离线扩充在训练前就把图片生成好增加的是磁盘上的样本数量。在线增强则是在每个epoch训练过程中实时对图片做变换每次读入的数据都有变化。两种方式的定位不一样离线扩充适合样本数量从少到多的阶段在线增强适合每张图被反复读取时制造差异。YOLO系列从v4开始引入Mosaic增强核心思路是每次取4张训练图缩放后拼成一张大图同步处理标签坐标。Mosaic的优势是显存利用率高一次能看到4张图的上下文信息。此外Mosaic通过对4张图做随机缩放等于在训练中引入了尺度变化对小目标特别友好。yolov8、yolov5以及近期的yolov26系列在数据增强上都保留了这类策略。如果你的训练数据里小目标占比高Mosaic几乎是标配。4.2 手动拼一个离线Mosaic数据集生成器既然Mosaic是训练时增强为什么还要手动离线生成便于排查标签坐标是否正确也可以把拼图结果直接送到后续流程里做二次标注检查。对于只有少量样本的起步阶段离线生成Mosaic数据再混合训练效果也很直接。import cv2 import numpy as np def mosaic_augment(img_list, label_list, output_size640): img_list: 4张图片路径 label_list: 4个对应YOLO标签文件路径 imgs [cv2.imread(p) for p in img_list] boxes_all [read_yolo_label(p) for p in label_list] # 随机选一个中心点决定四张图的切割位置 center_x np.random.randint(output_size // 4, output_size * 3 // 4) center_y np.random.randint(output_size // 4, output_size * 3 // 4) # 给每张图随机缩放模拟远近变化 scales [np.random.uniform(0.5, 1.5) for _ in range(4)] canvas np.zeros((output_size, output_size, 3), dtypenp.uint8) result_boxes [] # 四张图对应四个象限 positions [(0, 0), (0, 1), (1, 0), (1, 1)] for idx, (img, boxes) in enumerate(zip(imgs, boxes_all)): h, w img.shape[:2] nh, nw int(h * scales[idx]), int(w * scales[idx]) img_resized cv2.resize(img, (nw, nh)) # 根据缩放比例重算标签 for cls, xc, yc, bw, bh in boxes: px1 (xc - bw / 2) * nw py1 (yc - bh / 2) * nh px2 (xc bw / 2) * nw py2 (yc bh / 2) * nh boxes[idx] [cls, px1, py1, px2, py2] # 计算当前象限中可放下的宽高 quad_w output_size - center_x if positions[idx][1] 1 else center_x quad_h output_size - center_y if positions[idx][0] 1 else center_y # 超过象限大小的部分丢弃 mask np.zeros((nw, nh), dtypebool) # 找到子图在canvas上的放置位置 if positions[idx][1] 0: dx 0 else: dx center_x if positions[idx][0] 0: dy 0 else: dy center_y # 计算可显示的宽高 disp_w min(nw, output_size - dx) disp_h min(nh, output_size - dy) canvas[dy:dydisp_h, dx:dxdisp_w] img_resized[:disp_h, :disp_w] # 重新计算可见框的坐标丢弃完全不可见的 for cls, px1, py1, px2, py2 in boxes[idx]: vx1 max(px1, 0 - dx) # 这里简化处理实际要按canvas坐标计算 nx1 max(px1, 0) ny1 max(py1, 0) nx2 min(px2, disp_w) ny2 min(py2, disp_h) if nx1 nx2 or ny1 ny2: continue # 转回canvas坐标 cx1 nx1 dx cy1 ny1 dy cx2 nx2 dx cy2 ny2 dy # 丢弃过小的框 if cx2 - cx1 10 or cy2 - cy1 10: continue result_boxes.append([ cls, ((cx1 cx2) / 2) / output_size, ((cy1 cy2) / 2) / output_size, (cx2 - cx1) / output_size, (cy2 - cy1) / output_size ]) return canvas, result_boxesMosaic的标签换算要比翻转、裁剪复杂得多很多人在这一步翻车。核心是两层坐标系变换把原始图片的归一化坐标换算成缩放后图片的像素坐标再把超出拼接象限的部分裁掉最后换算成拼接大图的归一化坐标。代码里positions决定子图放在左上、右上、左下、右下哪个位置center_x、center_y是拼接点这两个变量直接决定每张子图可见区域的大小。缩放值建议在0.5到1.5之间太小目标细节丢失太大会让合成图过于密集目标重叠严重。4.3 训练参数里的扩充开关用yolov8训练自己数据集时超参数文件里有一批数据增强相关的参数。常见的组合如下参数项推荐值说明hsv_h0.015色调偏移幅度值太大会导致目标颜色失真hsv_s0.7饱和度偏移颜色敏感任务调低到0.3hsv_v0.4亮度偏移degrees10.0旋转角度范围单位是度超过15度标注框不贴合目标translate0.1平移比例0.1表示在10%范围内平移scale0.5缩放比例0.5表示0.75~1.25之间的缩放fliplr0.5水平翻转概率mosaic1.0Mosaic开关在训练后期可以关掉注意这些参数是训练时在线生效的不用改代码。小样本场景下建议先把scale调大到0.8让模型见到更多尺度变化再观察loss表现。如果模型在小目标上有明显漏检可以把mosaic保持开启同时关闭fliplr左右翻转会让文字、方向性目标错乱。整套参数遵循一个原则破坏性小的增强开到最大破坏标注语义的增强保守设置。5. 数据集扩充后的翻车现场四个常见坑与定位方法5.1 增强后GT框大面积越界训练loss不降现象扩充完数据后开始训练loss先是快速下降到某个epoch后开始抖动甚至反弹训练集和验证集指标都上不去。原因几何增强后标签坐标越界。比如随机裁剪时裁剪区域边缘正好切掉目标但代码没有做裁剪判断导致标注框的坐标跑到图片外面去了。YOLO在计算损失函数时会把越界的框当作正常目标来计算梯度方向直接被污染。解决在扩充脚本里对每个增强后的框做一次合法性检查。规则是中心点必须在0和1之间宽高必须大于0且小于1框的任意一条边都不能超出图片边界。超出的直接丢弃。另外在训练前做一个离线的全量校验把每个txt文件读一遍发现非法坐标就单独输出文件名核对后再继续。5.2 验证集也做了增强指标虚高无法复现现象扩充后验证集mAP涨了好几个点然而部署到实际环境里效果很差完全复现不了训练时的指标。原因把验证集图片也做了增强或者验证集和训练集共享了同一份增强数据。验证集被增强后模型在训练时就已经见过这些图片的变换版本相当于数据泄露。这是小样本扩充最容易犯的错误。解决验证集和测试集永远只做不影响语义的最小预处理比如统一resize到模型输入尺寸不做翻转、裁剪、颜色变换。标注框也不允许做任何修改。判断是不是这个问题的办法很简单——把验证集恢复成原始图片重新评估一次。指标掉下来基本就是验证集污染。5.3 复制粘贴产生大量重叠框NMS开始压制正确结果现象实例级扩充之后单张图片里目标数量暴增训练时显存占用明显升高推理时NMS把一些本应保留的框抑制掉了。原因复制粘贴时贴上去的目标和原有的目标位置重叠。训练时模型学到的是目标可以被其他目标大面积遮挡这个语义在某些场景下是对的但小样本数据集里合成遮挡容易过头。更关键的是如果原来的标注框没有清理干净两个框中心接近NMS会按置信度排序把低分的框删掉导致漏检。解决在paste_instance里加入IoU判断新放置的框与已有框IoU超过0.3就重新选位置。同时在推理阶段把NMS的iou_thres从默认的0.45适当调低到0.3给重叠目标更多保留空间。如果做的是密集场景检测且真实场景里目标本来就重叠维持数据和增强策略一致即可。5.4 Mosaic增强到自己的batch里显存先崩现象用离线Mosaic生成的数据集没有问题但训练到中途显存溢出CUDA out of memory报错出现。原因Mosaic生成的图片默认都是满分辨率比如640×640遇到原图分辨率太大时一个batch里的数据量远超过预期。另外如果同时开启Mosaic和在线增强图片在送入模型前还要做仿射变换中间张量进一步膨胀。解决在数据集加载阶段对图片做统一的letterbox处理把长边固定到模型输入尺寸。batch size从默认值减半观察显存占用稳定后再逐步加回来。mosaic增强建议只在训练的前中期开启最后十几个epoch关闭让模型适应无Mosaic的分布显存压力也随之降低。打开Mosaic之后给每个强制缩放期望一个可重复的随机种子排查问题时能稳定复现这是经验之谈。6. 用消融实验找到扩充上限再决定要不要上伪标签扩充方法不是越多越好。信号级、实例级、Mosaic组合在一起数据多样性上来了但也有可能把分布偏移放大最终指标反而不如只用其中一种。建议做一个消融实验矩阵先控制变量找出当前数据集上收益最大的扩充方法。实验设计很简单每次只改一个变量。先用原始数据训练一个baseline记录mAP。然后在baseline基础上只加信号级增强再跑一轮。接着单独加实例级增强单独加离线Mosaic。最后把表现最好的两个组合起来再跑一轮。每轮训练epoch固定不调其他参数。这样一轮下来大概能看出每种扩充方法的上限在哪。有一个快速验证的小技巧小样本场景下不用跑完整个训练流程先跑50个epoch看趋势就够。如果某个配置在30个epoch时验证集loss已经明显低于baseline说明这个扩充方向的收益是真实的再跑完整训练不迟。如果扩充做到一定程度后验证指标进入平台期不再上涨这时候数据集内部的信息已经被榨干了。再花时间拼更多的变换组合收益也很小这代表扩充方法到了天花板。下一步可以考虑半监督的方向。常见的做法是用训练好的模型对一批无标注图片做推理把置信度阈值调到0.9以上只保留高置信度的预测结果作为伪标签再合并进训练集重新训练。伪标签能扩充样本分布但风险也大。置信度阈值太低会把错误标签混进去模型越训越歪。所以我一般会限定只使用模型在多个epoch间稳定预测出来的目标而不是单次推理结果。先小规模验证伪标签加入后loss曲线是否顺滑下降确认有效再逐步放大加入比例。我自己的习惯是每新增一种扩充方法先跑一轮50epoch的消融实验记录验证集loss曲线图而不是只看最终指标。loss曲线能暴露很多最终指标掩盖的问题比如过拟合从第几个epoch开始、哪个阶段梯度方向混乱。这套流程走下来小样本扩充基本都在掌握之中。希望帮到你。本文还有配套的精品资源点击获取