1. 图像增强库 imgaug 到底解决什么问题
1.1 从一次“八百张图”的训练事故说起
几年前接手一个工业质检的小项目,正负样本加起来不到九百张,任务是找出画面里几类细微的表面缺陷。模型结构不算复杂,训练集上的损失三天就压到了几乎为零,验证集上的召回率却来回震荡,怎么调学习率都没用。当时的处境很典型:标注成本高,扩样周期长,唯一能立刻动手的就是数据增强。
最开始用的是常规做法,随机翻转、随机裁剪、亮度抖动,几行代码就搞定了。问题出在这批数据不只是“整张图分类”,还要输出缺陷所在的矩形框。翻转的时候我忘了同步改变框坐标,训练了两轮才发现标签整体错位,模型学出来的框全是镜像的。后来改成手写坐标变换,一遇到旋转、缩放、透视变换就彻底写不动了——每个几何操作都要自己推一遍框的四个角怎么变,写一次错一次。
这就是图像增强库imgaug出场的位置。它的核心价值不在于“能翻转能调亮度”,而在于把图像本身和它附带的所有标注当成一个整体一起变换:关键点、矩形框、多边形、分割掩膜、热力图,一次调用,坐标全部同步更新。你只需要描述“我想做哪些增强、参数范围是多少”,剩下的采样、应用、坐标换算它全包了。适合的人群很明确:做目标检测、实例分割、语义分割、人体关键点、姿态估计、医学影像、遥感解译、工业质检的工程师,以及任何需要在少量样本上把模型泛化能力撑起来的人。如果你只是做最普通的图像分类,翻转加裁剪就够用,那没必要上这套;但只要你的标签里带坐标,imgaug 的省心程度会立刻体现出来。
1.2 三个能力维度:几何、像素、结构
理解 imgaug 最快的方式,是把它提供的增强器按作用性质分成三层,而不是死记那几百个类名。
几何变换层处理的是像素的空间位置关系,包括仿射(旋转、平移、缩放、错切)、弹性形变、透视变换、裁剪与填充、翻转、分块扰动。这一层是标注同步的主战场,也是手写实现最容易出错的区域。比如iaa.Affine一个类就覆盖了旋转加缩放加平移的组合,背后是同一个仿射矩阵作用到图像和所有坐标点上。
像素变换层保持位置不动,只改像素值:亮度、对比度、饱和度、色相、伽马、添加高斯噪声、椒盐噪声、各类模糊、锐化、直方图均衡,还有一整套“天气模拟”(雨、雪、雾、云、影子)。这一层对标注无影响,可以随意叠加。
结构变换层是近些年增强策略里非常有效的一类,代表是CoarseDropout(随机遮挡若干矩形块)和Cutout。它强迫模型不要过度依赖局部特征,在细粒度分类和检测任务上经常能换来一两个点的提升,而且实现成本极低。
三层可以任意串联,真正的门槛不在于知道有哪些增强器,而在于知道哪些组合对你的任务是无害的。后面第 6 节会给出按任务划分的具体配方,这部分才是决定增强是帮忙还是添乱的关键。
1.3 与常见方案的正面对比
做技术选型的时候,把候选方案放在同一张表里对照,比看一百篇介绍文章都管用。下面这张表是我自己踩过坑之后整理的,评价维度偏向工程落地而不是功能罗列。
| 维度 | imgaug | torchvision.transforms | 其他主流增强库 |
|---|---|---|---|
| 标注同步 | 关键点、框、多边形、线段、分割图、热力图全覆盖,一次调用完成 | v2 版本支持框与掩膜,关键点支持较弱 | 框与掩膜支持良好,多边形与热力图视版本而定 |
| API 灵活度 | 极高,支持顺序、择一、择多、概率、嵌套组合 | 中等,以组合链为主 | 中高 |
| 执行速度 | 偏慢,纯 CPU、Python 层调度开销明显 | 快,部分操作可走张量 | 快,底层做了较多优化 |
| 维护活跃度 | 更新节奏放缓,新特性少 | 跟随主框架持续迭代 | 迭代较活跃 |
| 学习曲线 | 偏陡,概念多(增强器、随机状态、标注对象) | 平缓 | 中等 |
| 适合场景 | 标注复杂、需要精细控制随机性的项目 | 分类任务、与训练框架深度集成 | 通用检测分割任务 |
结论不是“谁替代谁”。我现在的习惯是:分类任务直接用框架自带的轻量方案,省依赖省启动时间;一旦标签里带坐标、尤其是带多边形和分割掩膜,就切到 imgaug,因为它在“几何变换 + 标注同步”这件事上的表达力确实少见对手。速度慢是它的软肋,但增强通常发生在数据加载的 worker 进程里,只要不是极端瓶颈,多开几个进程完全能盖过去。
2. API 设计背后的关键抉择
2.1 随机参数在增强器上采样,而不是在图元上
imgaug 最核心的一个设计,是增强器实例本身是一个“参数采样器”。当你写下iaa.Affine(rotate=(-15, 15))的时候,你并没有立刻得到一张旋转过的图,你得到的是一个配置对象。真正调用augment(image=...)的那一刻,它才从(-15, 15)这个区间里采样出一个具体角度,比如7.3度,然后把“旋转 7.3 度”这个确定操作应用到传进来的所有东西上。
这个细节听起来平平无奇,但它是所有标注能对齐的根本原因。如果图像转 7.3 度、框按 5.1 度转,标签立刻就废了。因为参数只采样一次、复用多次,图像和框才共享同一个变换矩阵。
与之配套的是一组容易混淆的方法名,搞清楚它们的差异能省下大量调试时间:
augment(image=..., bounding_boxes=...)是统一入口,返回多个结果,推荐用这个。augment_images(images)只处理图像数组,返回增强后的数组。- 带下划线后缀的
augment_images_系列是就地修改,会直接改掉你传进去的数组,做在线增强时要注意别把原始数据污染了。 to_deterministic()把当前随机状态“冻结”,返回一个不再随机的副本,后面每次调用结果都一样。做验证集可视化或者复现 bug 时必用。
另外有个常被忽略的性能细节:Sequential里如果所有增强器都是确定性的(比如你传了固定参数而不是区间),imgaug 会走一条更快的路径。反过来,如果你在循环里反复to_deterministic()或者在每次调用前重新构造 pipeline,开销会明显上升。pipeline 应该在 Dataset 初始化时构造一次,之后复用。
2.2 四种组合结构撑起所有策略
功能列表再长,组合方式其实就四种,理解了这四种,任何复杂策略你都能拼出来。
Sequential是顺序执行,一个接一个。它是骨架,绝大多数 pipeline 的最外层都是它。有个参数random_order=True,开启后每次调用会打乱内部增强器的顺序,这个用来防止模型对“固定增强顺序”产生依赖,代价是调试时更难定位问题,我一般只在最后收敛阶段打开。
OneOf是从一组里随机选一个执行。适合表达“互斥的几种风格”,比如阴天、雾天、逆光三选一,而不是全部叠上去。
SomeOf是从一组里随机选 n 个执行,n=(1, 3)这样写表示每次随机选 1 到 3 个。它比OneOf更灵活,也比Sequential全量叠加更温和,是做强度控制的主力。
Sometimes是带概率地执行某个子链,iaa.Sometimes(0.3, iaa.GaussianBlur(...))表示只有三成样本会被模糊。它解决的是“某类增强不该每张图都出现”的问题。
这四种可以任意嵌套。一个我常用的结构是用Sometimes包住一个SomeOf,实现“三成样本里,再从四个方面里随机挑两个弱化”,表达力足够覆盖大部分场景,同时保持了可读性。写成代码大概是这样:
import imgaug.augmenters as iaa pipeline = iaa.Sequential([ iaa.Sometimes(0.5, iaa.Fliplr(0.5)), iaa.Sometimes(0.3, iaa.SomeOf((1, 2), [ iaa.GaussianBlur(sigma=(0.0, 1.2)), iaa.AdditiveGaussianNoise(scale=(0, 0.05 * 255)), iaa.LinearContrast((0.85, 1.15)), iaa.Multiply((0.9, 1.1)), ])), iaa.Affine( rotate=(-12, 12), scale=(0.92, 1.08), translate_percent=(-0.06, 0.06), mode="reflect", ), ], random_order=False)这段大概是大部分检测任务的起点,后面只需要按任务微调数值区间。
2.3 标注对象是一等公民
imgaug 把各种标注封装成独立对象,统一放在imgaug.augmentables模块下。这个设计意味着你不需要自己维护“图像数组 + 一堆 numpy 坐标”的松散组合,而是构造一个有类型、有方法的对象,交给 pipeline 处理。
常用的几个:KeypointsOnImage承载一组关键点坐标,BoundingBoxesOnImage承载一组矩形框,PolygonsOnImage承载多边形(实例分割常用),SegmentationMapsOnImage承载整张的分割标签图,HeatmapsOnImage承载热力图目标。每个对象在构造时都需要传入完整的shape,也就是所属图像的高宽通道数,因为后续所有坐标计算都要用它做边界判断。
标注对象自带一批实用方法,这些比增强本身更有日常价值:clip_out_of_image()把越界的框裁回图像范围,remove_out_of_image()直接丢掉完全跑出去的框,BoundingBox上的is_out_of_image()、area、iou也都能直接调用。检测任务里最常见的脏数据——旋转之后跑到画面外面的框——一行clip_out_of_image()就干净了。
注意:旋转之后的矩形框会被重新计算成外接矩形,原来紧凑的框会变松。如果你的任务对小目标定位精度要求很高,旋转增强的幅度就要克制,或者改用分割掩膜标注来规避这个问题。
3. 容易被忽略的核心细节
3.1 数据类型与数值范围
图像增强库对 dtype 的敏感程度远超大多数人的预期。imgaug 的约定比较宽松:uint8的0~255和float32的0.0~1.0都接受,内部会按 dtype 推断取值范围。宽松的另一面是容易出事故——有些增强器的行为会随 dtype 变化。
最典型的是各类“加法”型增强器。iaa.Add(value=30)在uint8上就是把像素加 30,很直观;但如果你的图是float32且范围在0~1,那value=30会直接把整张图打成纯白。正确写法是用to_value这类相对参数,或者干脆统一转成uint8再送进去。我现在的习惯非常固定:Dataset 里读出来先转uint8,所有增强在uint8空间完成,进网络前再统一归一化成float32。这样做的好处是增强的视觉效果和你在图像查看器里看到的一致,调参时不会出现“代码里看着正常、训练时数值溢出”的割裂感。
还有一点是溢出截断。uint8空间的加法会饱和到 255,这在模拟过曝时是想要的效果;但在float空间里如果不裁剪,可能出现负值或者大于 1 的值,进网络后引发梯度异常。所以归一化之后顺手np.clip一下,是个成本极低的好习惯。
3.2 通道顺序与颜色空间
imgaug 默认假设图像数组的形状是(H, W, C),通道在最后一维。灰度图要么保持(H, W)的二维形状,要么补成(H, W, 1),两种它都能处理,但混用容易在同一批数据里出问题。如果你的数据管道里既有彩色图又有灰度图,最稳妥的做法是全部补成三通道,别指望增强器替你做判断。
通道含义则是另一回事。OpenCV 读出来是 BGR,PIL 读出来是 RGB,imgaug 完全不关心你用的是哪种,它只按通道数处理。所以颜色类增强器(色相、饱和度、色彩空间转换)在不同读取方式下效果会不一样。实践中的处理方式是固定的:读取后立刻统一成 RGB,增强、可视化、保存全流程保持 RGB,只在需要调用 OpenCV 特定函数时才临时转一下。这条规则写进项目文档里,能避免团队里每个人各自为政。
还有人会踩“用错色彩空间”的坑。iaa.WithColorspace系增强器需要显式指定目标空间,如果在一个本来就是灰度的图上做 HSV 相关操作,效果会很奇怪。遇到颜色增强效果不对,第一反应应该是打印数组形状和通道数,而不是继续调参数。
3.3 坐标语义与越界处理
矩形框的坐标语义在不同库里不统一,这是个经典陷阱。imgaug 的BoundingBox(x1, y1, x2, y2)使用的是连续坐标,x2和y2是框的边界而非最后一个像素的索引。也就是说一个覆盖第 0 到第 9 列的框,写出来是x1=0, x2=10而不是x2=9。如果从某些标注工具导出的数据是按像素索引记的,直接喂进去框会整体缩一个像素,小目标上这个偏差相当致命。
关键点则是浮点坐标,不做取整,这点很好,避免了多次增强累积误差。但反过来说,如果你要把增强后的关键点画到图上做检查,记得自己取整,否则绘图函数可能报类型错误。
越界处理要形成一个固定的收尾动作。增强完之后,我一般会连做三件事:
bbs_aug = bbs_aug.clip_out_of_image() bbs_aug = bbs_aug.remove_out_of_image() # 再过滤掉太小或太扁的框因为裁剪、平移、旋转都可能产生面积只剩下几个像素的“残框”,这些框在训练时是纯噪声,标注质量比数量重要得多。加一个面积阈值过滤(比如小于原面积的 15% 就丢),在很多检测任务上能立刻看到效果。
提示:分割掩膜经过几何变换时,重采样方式必须用最近邻。
SegmentationMapsOnImage在内部已经处理了这件事,但如果你自己用 OpenCV 转了一道,一定要显式指定INTER_NEAREST,否则类别编号会被插值成中间值,变成不存在的类别。
3.4 随机性、确定性与复现
分布式训练和多进程加载的时候,随机性失控是非常令人抓狂的问题:同一份代码两次跑出来指标差三个点,排查半天发现是增强的随机源不可控。
imgaug 的随机状态管理有几层。全局层是ia.seed(42),设置后所有增强器的默认随机源都跟着走。但这个全局种子在多线程或多进程环境下并不安全,各进程会共享同一个状态对象,行为难以预测。更稳的做法是用seq.localize_random_state()给 pipeline 复制出一份独立的随机状态,然后在每个 worker 里各自持有一份。
真正推荐的做法是按样本派生种子。在 Dataset 的__getitem__里,用样本索引加上一个 epoch 偏移量构造出确定性的随机状态,再调用to_deterministic():
import imgaug as ia def __getitem__(self, idx): img, ann = self.load(idx) seed = (self.epoch * 100003 + idx) % (2 ** 32) rng = ia.random.RandomState(seed) seq_det = self.pipeline.to_deterministic().localize_random_state(rng) img_aug, ann_aug = seq_det(image=img, bounding_boxes=ann) return img_aug, ann_aug这样做的收益是双向的:训练时每个 epoch 的增强结果都不同(因为 epoch 参与了种子),但任何一个具体的(epoch, idx)组合都能精确复现。出了问题想回看某张图的增强效果,直接按索引取出来就行,不用靠运气。
4. 从零搭一条能上生产的流水线
4.1 环境准备与版本约束
依赖层面,imgaug 需要数值计算、图像读写、几何运算这几类基础库,安装时会被自动拉下来。真正需要说明的是版本兼容问题,这也是我在不同机器上重装环境时踩得最多的地方。
它在某些较新的数值库版本下会抛异常,错误信息通常指向某个已经被移除的旧类型别名。遇到这种情况有两条路:一是把数值库钉在较老的版本上,二是改用较老的解释器版本建立独立环境。我倾向于后者,为图像增强单独建一个环境,把解释器版本控制在 3.9 或 3.10,依赖锁死,不再动它。数据增强这种“跑起来就不用管”的环节,稳定性远比用上新特性重要。
# 建议的安装顺序,先钉住数值库再装主库 pip install "numpy<1.24" opencv-python-headless scipy imageio pip install imgaug装完之后花十秒验证一下,比训练到一半报错划算得多:
import numpy as np import imgaug as ia import imgaug.augmenters as iaa img = (np.random.rand(64, 64, 3) * 255).astype(np.uint8) seq = iaa.Sequential([iaa.Fliplr(0.5), iaa.Affine(rotate=(-10, 10))]) out = seq(image=img) print(out.shape, out.dtype)这段跑通,说明基础链路没问题。
4.2 定义 pipeline:从需求倒推结构
写 pipeline 的顺序应该是“先列约束,再挑增强器”,而不是打开文档把类名一个个试过去。以检测任务为例,先问自己几个问题:目标有没有方向性?如果有,翻转就不能用。缺陷是否总出现在画面中央?如果是,大比例裁剪会切掉目标。图像的采集条件是否稳定?如果光照很一致,那么大幅度的亮度扰动反而会让模型学到不存在的分布。
按这个思路,一条面向表面缺陷检测的 pipeline 可以这样组织:
import imgaug.augmenters as iaa train_aug = iaa.Sequential([ # 方向敏感任务:不做水平翻转 iaa.Sometimes(0.4, iaa.Affine( rotate=(-8, 8), scale=(0.95, 1.05), translate_percent=(-0.04, 0.04), mode="reflect", )), # 光照模拟:采集环境有波动,但幅度不大 iaa.Sometimes(0.6, iaa.OneOf([ iaa.Multiply((0.9, 1.1)), iaa.LinearContrast((0.9, 1.1)), iaa.Add((-12, 12)), ])), # 采集噪声 iaa.Sometimes(0.3, iaa.AdditiveGaussianNoise(scale=(0, 0.03 * 255))), # 轻微模糊,模拟对焦波动 iaa.Sometimes(0.2, iaa.GaussianBlur(sigma=(0, 0.8))), # 遮挡,提升鲁棒性 iaa.Sometimes(0.25, iaa.CoarseDropout( (0.01, 0.05), size_percent=(0.02, 0.06), per_channel=False)), ]) val_aug = iaa.Sequential([]) # 验证集不做随机增强几个数值的选择理由值得说明。旋转控制在正负 8 度,是因为更大的角度会让矩形框的外接矩形明显膨胀,标签质量下降的代价超过了增强收益。平移只给到 4%,因为目标常常贴着画面边缘,再大就被裁掉了。CoarseDropout的遮挡比例压得很低,缺陷本身就很微小,遮太多等于把正样本破坏成负样本。
对比一下,如果换成普通的图像分类任务,自由度就大得多:
cls_aug = iaa.Sequential([ iaa.Fliplr(0.5), iaa.Sometimes(0.5, iaa.Affine( rotate=(-20, 20), scale=(0.85, 1.15), shear=(-8, 8), mode="reflect", )), iaa.SomeOf((1, 3), [ iaa.LinearContrast((0.8, 1.2)), iaa.Multiply((0.85, 1.15)), iaa.AddToHueAndSaturation((-15, 15)), iaa.GaussianBlur(sigma=(0, 1.5)), iaa.AdditiveGaussianNoise(scale=(0, 0.05 * 255)), ]), iaa.Sometimes(0.3, iaa.CoarseDropout( (0.02, 0.08), size_percent=(0.03, 0.1))), ])差异的核心在于:分类任务丢掉了位置信息,几何变换的容错空间大;检测和分割保留坐标系,每一个几何操作都要对标签负责。
4.3 与数据加载流程结合
把增强嵌进 Dataset 是最主流的用法。这里有两个坑必须提前处理:变长标注的批处理,以及多进程下的随机状态。
变长标注指的是每张图的关键点或框数量不同,直接torch.stack会失败。解决办法是自定义 collate 函数,把标注保留为列表,在损失计算时再拼。或者更省事的做法——在 Dataset 里统一把图像 resize 到固定尺寸,让增强后的输出形状一致。
import torch from torch.utils.data import Dataset, DataLoader class DefectDataset(Dataset): def __init__(self, samples, pipeline, image_size=(512, 512)): self.samples = samples self.pipeline = pipeline self.image_size = image_size self.epoch = 0 def set_epoch(self, epoch): self.epoch = epoch def __len__(self): return len(self.samples) def __getitem__(self, idx): img, boxes = self.samples[idx] # img: uint8 HWC, boxes: list bbs = BoundingBoxesOnImage( [BoundingBox(*b) for b in boxes], shape=img.shape) seed = (self.epoch * 100003 + idx) % (2 ** 32) seq_det = self.pipeline.to_deterministic() \ .localize_random_state(ia.random.RandomState(seed)) img_a, bbs_a = seq_det(image=img, bounding_boxes=bbs) bbs_a = bbs_a.remove_out_of_image().clip_out_of_image() arr = np.stack([b.to_xyxy_array() for b in bbs_a.bounding_boxes]) \ if len(bbs_a.bounding_boxes) else np.zeros((0, 4), np.float32) img_t = torch.from_numpy(img_a).permute(2, 0, 1).float() / 255.0 return img_t, torch.from_numpy(arr.astype(np.float32))每个 epoch 开始前调一次set_epoch,就能保证不同轮次看到的增强结果不同。这个小小的设计换来的是可复现性,出问题时能精确定位到“第几轮第几个样本”。
多进程加载时,num_workers开多大取决于机器核数和单张图的增强耗时。我一般在 4 到 8 之间取舍,同时把persistent_workers=True打开,避免每个 epoch 重新拉起进程带来的固定开销。如果发现 GPU 利用率低于 60%,八成是数据侧跟不上,先做性能分析再决定是减增强还是加 worker。
4.4 验收:不看图就等于没做
这一步被跳过的频率高得离谱,但它的性价比是所有环节里最高的。增强写完之后,随机抽 8 到 16 个样本,把增强后的图和标注一起画出来,拼成一张大图存到本地,肉眼过一遍。
import cv2 import numpy as np def inspect(samples, pipeline, n=8, path="check.jpg"): rows = [] for i in range(n): img, boxes = samples[i] bbs = BoundingBoxesOnImage([BoundingBox(*b) for b in boxes], img.shape) img_a, bbs_a = pipeline.to_deterministic()(image=img, bounding_boxes=bbs) vis = bbs_a.draw_on_image(img_a, size=2, color=[0, 255, 0]) rows.append(cv2.resize(vis, (256, 256))) grid = np.vstack([np.hstack(rows[j:j+4]) for j in range(0, n, 4)]) cv2.imwrite(path, grid[:, :, ::-1]) # BGR 转 RGB 再存看什么呢?看三样东西。第一,框是不是还老老实实贴在目标上,尤其是经过旋转和缩放之后。第二,有没有出现“看着就假”的图,比如噪点密到目标都看不清、遮挡块正好盖住了唯一的缺陷。第三,整体色调是不是还在合理区间,有没有某类增强在某些图上产生了极端值。这三条任何一条不过,回去改参数,不要指望模型自己扛过去。
5. 排查实录与避坑清单
5.1 形状相关报错
最常见的一条是“图像形状不一致”类的异常,通常发生在批量增强的时候。根因是你往augment_images里塞了一个形状各异的列表。imgaug 的批量接口假设同批图像尺寸统一,不同尺寸必须分开调用。
处理方式有两种。一是对单张图逐个调用,损失一点速度但逻辑简单;二是进 Dataset 之前统一 resize 或 padding 到固定尺寸。后者更适合训练,因为下游的 collate 也需要统一形状。这里有个细节:resize 之后再增强,旋转产生的黑边会落在固定边界内,比“先增强再 resize”更容易控制;但 resize 会改变小目标的相对尺寸,如果目标极小,建议在 resize 时用 letterbox 方式保持长宽比,避免拉伸变形。
另外,灰度图和彩色图混在一个 batch 里也会触发形状问题。前面提过,统一补通道是成本最低的解法。
5.2 标注漂移与框质量下降
框和图像对不上,通常有三个来源。第一种是前面说的坐标语义错位,x2到底含不含边界,不同来源的数据不统一。第二种是只把图像传给了增强器,忘了传标注对象,那框自然还是原坐标。第三种是几何变换的副作用,旋转、错切之后的外接矩形比原框大了一圈,多轮增强叠加下来框会越来越松。
前两种好办,统一走augment(image=..., bounding_boxes=...)的入口,不用分开调用。第三种需要在流程末尾加过滤:面积比、长宽比、置信度都可以作为过滤依据。我常用的是“增强后框面积不超过原面积的 1.6 倍,且不小于原面积的 0.4 倍”,超出的直接丢弃或者回退到原始样本。看起来粗暴,但实测比不做过滤稳得多。
注意:不要把增强后的框再拿去和原图配对做可视化检查,坐标系已经变了。要检查就检查增强后的图配增强后的框。
5.3 速度慢、内存涨
imgaug 的性能瓶颈集中在 Python 层的调度和逐图处理上。几个对症的方向,按收益排序:
| 现象 | 常见原因 | 处理办法 |
|---|---|---|
| 训练时 GPU 利用率低 | 数据侧吞吐不足 | 增大num_workers,开persistent_workers |
| 单张增强耗时超过 40ms | 叠加了多个重增强器 | 用SomeOf控制每次只走 1 到 2 个 |
| 内存随训练持续上涨 | 在 Dataset 里缓存了增强结果 | 只缓存原图,增强永远在线做 |
| 大分辨率图处理极慢 | 全尺寸增强 | 先降采样增强再放大,或直接减小输入分辨率 |
| 可视化时卡死 | 一次性画了几百张 | 抽查 8 到 16 张即可 |
有一个容易被忽视的点:在线增强永远不要在 Dataset 的__init__里预先生成。有人图省事,初始化时把数据扩增十倍存进内存,结果内存直接爆掉,而且每轮看到的都是同一批固定增强结果,等于把在线增强退化成了离线扩样,效果打折扣。增强的意义就在于每个 epoch 不一样。
5.4 版本与依赖冲突
这类问题的表现是“代码没错但跑不起来”,错误信息五花八门。我遇到的典型场景是:环境里其他库把数值计算库升级到了新版本,导致 imgaug 内部引用了已被移除的旧别名,导入阶段就失败。判断方法很简单,单独跑一次导入测试,能复现就是版本问题,不能复现就是代码问题。
解决路径前面已经提过,锁环境最稳。额外补一条:如果项目必须用新版数值库,另一个办法是在应用启动的最前面打补丁,把缺失的别名补回去。这种做法治标不治本,但在“老项目不想动环境”的场景下很实用。
import numpy as np if not hasattr(np, "bool"): np.bool = bool if not hasattr(np, "int"): np.int = int if not hasattr(np, "float"): np.float = float把这段放在所有 import 之前,能挡掉不少兼容性报错。当然,长期方案还是隔离环境。
5.5 增强过头与增强不足
这两类问题的共同点是训练曲线看着还行,但线下测试崩得厉害。增强过头表现为训练损失下降很慢、始终收不敛;增强不足表现为训练集精度极高、验证集一塌糊涂。判断方法是做一个消融:固定模型和超参,只把增强强度调低一半再跑一次,对比验证集曲线。
我的经验阈值是——如果一张增强后的图,人眼已经很难确认它属于原来的类别,那这个增强就过强了。缺陷检测这类细微目标尤其如此,CoarseDropout的比例一旦超过 10%,很可能把唯一的正样本遮没。相反,分类任务里稍微激进一点通常无妨,因为类别信息分布在全图。
6. 按任务给配方:增强强度怎么定
6.1 分类任务:可以放开手脚
分类任务丢掉了空间信息,这是最大的自由度来源。旋转、缩放、错切、透视、弹性形变统统可以上,只要不把类别语义改掉。比如区分“猫和狗”,水平翻转完全没问题;但如果是区分“左转箭头和右转箭头”,翻转就直接把标签改了,这时候得改用不改变方向性的增强。
一条通用的分类配方是:翻转 0.5 概率,仿射在正负 20 度、0.85 到 1.15 缩放之间,色彩扰动三选一,加一点模糊和噪声,再用CoarseDropout做轻微遮挡。这套组合在中小规模数据集上通常能带来稳定收益。
要提醒的是色彩增强的边界。如果你的数据集本身颜色就是判别依据(比如区分不同颜色的产品型号),大幅度的色相扰动会破坏标签。这时候把色相相关的增强全部关掉,只保留亮度和对比度小幅波动。
6.2 检测与分割:每一步都要对标签负责
检测和分割的约束多得多,核心原则是不能让标注失效。具体来说有三条必须守住的线:
几何增强的幅度要小。旋转控制在正负 10 度以内,缩放控制在 0.9 到 1.1,平移不超过画面尺寸的 5%。幅度再大,外接矩形膨胀和边界截断的问题就会盖过增强收益。裁剪要格外小心,检测任务里大比例裁剪极易把目标切掉一半,建议只用CropAndPad的小幅负值(比如percent=(-0.08, 0.05)),而不是大刀阔斧地切。
方向性增强要按数据特点决定。水平翻转在自然图像检测里几乎必开,但在工业质检、医学影像里往往有害——划痕的方向、病灶的左右分布都是有意义的先验。这一点没有通用答案,得看你的数据采集方式和业务语义。
遮挡类增强要克制。CoarseDropout在分类上很好用,在检测上要小心,因为遮挡块可能正好盖住目标,而标签还是原来的框,模型会被迫学习“看不见也要检出”。少量用(概率 0.2 以内、面积 5% 以内)作为正则手段是可以的。
分割任务还多一层要求:掩膜的重采样必须是最近邻,类别编号不能被插值污染。用SegmentationMapsOnImage走统一入口就不用操心这件事。
6.3 小样本场景的取舍
回到开头那个八百张图的项目。最终跑通的方案并不复杂:仿射小幅度、亮度对比度扰动、轻噪声、轻模糊,配上 0.2 概率的小面积遮挡,num_workers=6,训练 60 轮,验证集召回从最初的 0.61 提到了 0.78。真正带来提升的不是某个花哨的增强器,而是三件事做对了:标注和图像严格同步、增强后做了抽查、强度控制在“人眼还能认出来”的范围内。
小样本场景下,我见过太多人一上来就堆十几个增强器,结果模型学了个四不像。合理的顺序是先加最贴合数据分布的增强(采集条件波动就做光照,设备抖动就做模糊),跑一轮看曲线,有效果再加下一层。每次只改一个变量,才知道是谁在起作用。
最后分享一个我一直在用的小技巧:给 pipeline 里的每一类增强单独建一个开关,写成配置字典,训练脚本从配置里读。这样调参的时候改一行配置就行,不用翻代码。跑十几组对照实验之后,你会对“哪类增强对自己的数据有效”形成直觉,这个直觉比任何通用配方都值钱。