如有错误,恳请指出。
在之前使用opencv就介绍使用过一些常用的数据增强的实现方法,见:《数据增强 | 旋转、平移、缩放、错切、HSV增强》,当时介绍了旋转、平移、缩放、错切、HSV增强,但是只是针对了图像的数据增强,并没有涉及到label的变化。
因为,对于分类任务的数据增强来说,只需要对图像进行数据增强就可以了;但是对于目标检测任务来说,图像改变了,也以为着label的位置改变,同样需要作出变换,所以这里我介绍一下目标检测下的数据增强方法。
这节来介绍其他的数据正确方式,比如仿射变换还有hsv增强,虽然之前我使用opencv进行了部分尝试,详细见:数据增强 | 旋转、平移、缩放、错切、HSV增强,不过这里还是更加yolov3-spp代码进行补充。
YOLOv3-SPP代码:
# train阶段默认为:
# img:(1472, 1472, 3), targets:(k, 5)
# 旋转角度degrees: 0.0, 平移系数translate: 0.0, 缩放因子scale=0.0, 错切角度shear:0.0
# border=-368
def random_affine(img, targets=(), degrees=10, translate=.1, scale=.1, shear=10, border=0):
"""
仿射变换 增强
torchvision.transforms.RandomAffine(degrees=(-10, 10), translate=(.1, .1), scale=(.9, 1.1), shear=(-10, 10))
https://medium.com/uruvideo/dataset-augmentation-with-random-homographies-a8f4b44830d4
:param img: img4 [2 x img_size, 2 x img_size, 3]=[1472, 1472, 3] img_size为我们指定的图片大小
:param targets: labels4 [:, cls+x1y1x2y2]=[7, 5] 相对img4的 (x1,y1)左下角 (x2,y2)右上角
:param degrees: 旋转角度 0
:param translate: 水平或者垂直移动的范围 0
:param scale: 放缩尺度因子 0
:param shear: 裁剪因子 0
:param border: -368 图像每条边需要裁剪的宽度 也可以理解为裁剪后的图像与裁剪前的图像的border
:return: img: 经过仿射变换后的图像 img [img_size, img_size, 3]
targets=[3, 5] 相对仿射变换后的图像img的target 之所以这里的target少了,是因为仿射变换使得一些target消失或者变得极小了
"""
# 对图像进行仿射变换
# torchvision.transforms.RandomAffine(degrees=(-10, 10), translate=(.1, .1), scale=(.9, 1.1), shear=(-10, 10))
# targets = [cls, xyxy]
# 最终输出的图像尺寸,等于img4.shape / 2
height = img.shape[0] + border * 2
width = img.shape[1] + border * 2
# Rotation and Scale
# 生成旋转以及缩放矩阵
R = np.eye(3) # 生成对角阵
a = random.uniform(-degrees, degrees) # 随机旋转角度
s = random.uniform(1 - scale, 1 + scale) # 随机缩放因子
R[:2] = cv2.getRotationMatrix2D(angle=a, center=(img.shape[1] / 2, img.shape[0] / 2), scale=s)
# Translation
# 生成平移矩阵
T = np.eye(3)
T[0, 2] = random.uniform(-translate, translate) * img.shape[0] + border # x translation (pixels)
T[1, 2] = random.uniform(-translate, translate) * img.shape[1] + border # y translation (pixels)
# Shear
# 生成错切矩阵
S = np.eye(3)
S[0, 1] = math.tan(random.uniform(-shear, shear) * math.pi / 180) # x shear (deg)
S[1, 0] = math.tan(random.uniform(-shear, shear) * math.pi / 180) # y shear (deg)
# Combined rotation matrix
# 将三个仿射变换矩阵相乘,即可得到最后的仿射变换矩阵
M = S @ T @ R # ORDER IS IMPORTANT HERE!!
if (border != 0) or (M != np.eye(3)).any(): # image changed
# 进行仿射变化
# 最后输出的图像大小为dsize=(width, height)
img = cv2.warpAffine(img, M[:2], dsize=(width, height), flags=cv2.INTER_LINEAR, borderValue=(114, 114, 114))
# Transform label coordinates
# 对图像的label信息进行仿射变换
n = len(targets)
if n:
# warp points
xy = np.ones((n * 4, 3))
# 求出所有目标边界框的四个顶点(x1y1, x1y2, x2y1, x2y2)
# x1:1, y1:2, x2:3, y2:4
# x1y1:(1,2), x2y2:(3,4), x1y2:(1,4), x2y1:(3,2)
xy[:, :2] = targets[:, [1, 2, 3, 4, 1, 4, 3, 2]].reshape(n * 4, 2) # x1y1, x2y2, x1y2, x2y1
# 对四个顶点坐标进行仿射变换,就是与仿射矩阵进行相乘
# 对于仿射矩阵中,最后一行是没有用的;所有对于坐标点需要增加一维,矩阵相乘后去除
# 这里算是矩阵相乘的一个小trick,比较细节
# [4*n, 3] -> [n, 8]
xy = (xy @ M.T)[:, :2].reshape(n, 8)
# create new boxes
# 再求出仿射变换后的所有x坐标与y坐标
# 对transform后的bbox进行修正(假设变换后的bbox变成了菱形,此时要修正成矩形)
x = xy[:, [0, 2, 4, 6]] # [n, 4]
y = xy[:, [1, 3, 5, 7]] # [n, 4]
# 这里取xy的最小值作为新的边界框的左上角,取xy的最大值最为新的边界框的右下角
# 因为随机变换有可能将图像进行旋转,那么边界框也会选择,所以这时候需要对选择的边界框进行修正为不旋转的矩形,而不是菱形◇
xy = np.concatenate((x.min(1), y.min(1), x.max(1), y.max(1))).reshape(4, n).T # [n, 4]
# reject warped points outside of image
# 对坐标进行裁剪,防止越界,最小值为0,最大值为对于的宽高
xy[:, [0, 2]] = xy[:, [0, 2]].clip(0, width)
xy[:, [1, 3]] = xy[:, [1, 3]].clip(0, height)
w = xy[:, 2] - xy[:, 0]
h = xy[:, 3] - xy[:, 1]
# 计算调整后的每个box的面积:{ndarray:(9,)}
area = w * h
# 计算调整前的每个box的面积
area0 = (targets[:, 3] - targets[:, 1]) * (targets[:, 4] - targets[:, 2])
# 计算每个box的比例
ar = np.maximum(w / (h + 1e-16), h / (w + 1e-16)) # aspect ratio
# 选取长宽大于4个像素,且调整前后面积比例大于0.2,且比例小于10的box
i = (w > 4) & (h > 4) & (area / (area0 * s + 1e-16) > 0.2) & (ar < 10)
# 筛选边界框,所以其实经过仿射变换后的有些标签信息是使用不上的,也就是被忽略掉了
targets = targets[i]
# 变换后的边界框信息重新赋值
targets[:, 1:5] = xy[i]
return img, targets
# 对图像与标签应用仿射变换
img4, labels4 = random_affine(img4, labels4, # 输入图片与边界框信息
degrees=self.hyp['degrees'], # 旋转角度
translate=self.hyp['translate'], # 平移系数
scale=self.hyp['scale'], # 缩放系数
shear=self.hyp['shear'], # 错切角度
border=-s // 2) # 这里的s是期待输出图片的大小
旋转后需要对边界框坐标进行修正:
# create new boxes
# 再求出仿射变换后的所有x坐标与y坐标
# 对transform后的bbox进行修正(假设变换后的bbox变成了菱形,此时要修正成矩形)
x = xy[:, [0, 2, 4, 6]] # [n, 4]
y = xy[:, [1, 3, 5, 7]] # [n, 4]
# 这里取xy的最小值作为新的边界框的左上角,取xy的最大值最为新的边界框的右下角
# 因为随机变换有可能将图像进行旋转,那么边界框也会选择,所以这时候需要对选择的边界框进行修正为不旋转的矩形,而不是菱形◇
xy = np.concatenate((x.min(1), y.min(1), x.max(1), y.max(1))).reshape(4, n).T # [n, 4]
最后的debug结果:
labels会丢失部分标签信息,而img的大小仍然是736x736
部分参考,见之前的笔记:数据增强 | 旋转、平移、缩放、错切、HSV增强
YOLOv3-SPP代码:
def augment_hsv(img, h_gain=0.5, s_gain=0.5, v_gain=0.5):
"""
hsv增强 处理图像hsv,不对label进行任何处理
:param img: 待处理图片 BGR [736, 736]
:param h_gain: h通道色域参数 用于生成新的h通道
:param s_gain: h通道色域参数 用于生成新的s通道
:param v_gain: h通道色域参数 用于生成新的v通道
:return: 返回hsv增强后的图片 img
"""
# 从-1~1之间随机生成3随机数与三个变量进行相乘
r = np.random.uniform(-1, 1, 3) * [h_gain, s_gain, v_gain] + 1 # random gains
hue, sat, val = cv2.split(cv2.cvtColor(img, cv2.COLOR_BGR2HSV))
dtype = img.dtype # uint8
# 分别针对hue, sat以及val生成对应的Look-Up Table(LUT)查找表
x = np.arange(0, 256, dtype=np.int16)
lut_hue = ((x * r[0]) % 180).astype(dtype)
lut_sat = np.clip(x * r[1], 0, 255).astype(dtype)
lut_val = np.clip(x * r[2], 0, 255).astype(dtype)
# 使用cv2.LUT方法利用刚刚针对hue, sat以及val生成的Look-Up Table进行变换
img_hsv = cv2.merge((cv2.LUT(hue, lut_hue), cv2.LUT(sat, lut_sat), cv2.LUT(val, lut_val))).astype(dtype)
aug_img = cv2.cvtColor(img_hsv, cv2.COLOR_HSV2BGR, dst=img) # no return needed
# 这里源码是没有进行return的,不过我还是觉得return一下比较直观了解
return aug_img
YOLOv3-SPP代码:
# 平移增强 随机左右翻转 + 随机上下翻转
if self.augment:
# 随机左右翻转
# random left-right flip
lr_flip = True
# random.random() 生成一个[0,1]的随机数
if lr_flip and random.random() < 0.5:
img = np.fliplr(img) # np.fliplr 将数组在左右方向翻转
if nL:
labels[:, 1] = 1 - labels[:, 1] # 1 - x_center label也要映射
# 随机上下翻转
# random up-down flip
ud_flip = False
if ud_flip and random.random() < 0.5:
img = np.flipud(img) # np.flipud 将数组在上下方向翻转。
if nL:
labels[:, 2] = 1 - labels[:, 2] # 1 - y_center label也要映射
这里我贴上yolov3-spp中所有的__getitem__处理过程,详情见注释。
YOLOv3-SPP代码:
# 自定义数据集
class LoadImagesAndLabels(Dataset): # for training/testing
def __init__(self,
path, # 指向data/my_train_data.txt路径或data/my_val_data.txt路径
# 这里设置的是预处理后输出的图片尺寸
# 当为训练集时,设置的是训练过程中(开启多尺度)的最大尺寸
# 当为验证集时,设置的是最终使用的网络大小
img_size=416,
batch_size=16,
augment=False, # 训练集设置为True(augment_hsv),验证集设置为False
hyp=None, # 超参数字典,其中包含图像增强会使用到的超参数
rect=False, # 是否使用rectangular training
cache_images=False, # 是否缓存图片到内存中
single_cls=False, pad=0.0, rank=-1):
...
# 注意: 开启rect后,mosaic就默认关闭
self.mosaic = self.augment and not self.rect
...
# 自定义处理格式
def __getitem__(self, index):
"""
一般一次性执行batch_size次
Args:
self: self.img_files: 存放每张照片的地址
self.label_files: 存放每张照片的label的地址
self.imgs=[None] * n cache image 恐怕没那么大的显存
self.labels: 存放每4张图片的label值 [cls+xywh] xywh都是相对值 cache label
并在cache label过程中统计nm, nf, ne, nd等4个变量
self.batch: 存放每张图片属于哪个batch self.shape: 存放每张图片原始的shape
self.n: 总的图片数量 self.hyp self.img_size
数据增强相关变量: self.augment; self.rect; self.mosaic
rect=True: 会生成self.batch_shapes 每个batch的所有图片统一输入网络的shape
index: 传入要index再从datasets中随机抽3张图片进行mosaic增强以及一系列其他的增强,且label同时也要变换
Returns:
torch.from_numpy(img): 返回一张增强后的图片(tensor格式)
labels_out: 这张图片对应的label (class, x, y, w, h) tensor格式
self.img_files[index]: 当前这张图片所在的路径地址
shapes: train=None val=(原图hw),(缩放比例),(pad wh) 计算coco map时要用
index: 当前这张图片的在self.中的index
"""
hyp = self.hyp
# 训练过程使用mosaic数据增强
if self.mosaic:
# load mosaic
img, labels = load_mosaic(self, index)
shapes = None
# 推理阶段使用rect加快推理过程
else:
# load image
img, (h0, w0), (h, w) = load_image(self, index)
# letterbox
shape = self.batch_shapes[self.batch[index]] if self.rect else self.img_size # final letterboxed shape
img, ratio, pad = letterbox(img, shape, auto=False, scale_up=self.augment)
shapes = (h0, w0), ((h / h0, w / w0), pad) # for COCO mAP rescaling
# load labels
labels = []
x = self.labels[index]
if x.size > 0:
# Normalized xywh to pixel xyxy format
labels = x.copy() # label: class, x, y, w, h
labels[:, 1] = ratio[0] * w * (x[:, 1] - x[:, 3] / 2) + pad[0] # pad width
labels[:, 2] = ratio[1] * h * (x[:, 2] - x[:, 4] / 2) + pad[1] # pad height
labels[:, 3] = ratio[0] * w * (x[:, 1] + x[:, 3] / 2) + pad[0]
labels[:, 4] = ratio[1] * h * (x[:, 2] + x[:, 4] / 2) + pad[1]
# 是否进行数据增强
if self.augment:
# 由于mosaic中已经进行了random_affine,所以不需要;没有进行mosaic才需要
if not self.mosaic:
img, labels = random_affine(img, labels,
degrees=hyp["degrees"],
translate=hyp["translate"],
scale=hyp["scale"],
shear=hyp["shear"])
# Augment colorspace: hsv数据增强, 这一部分由于没有对标签进行更改,所以不需要对边界框进行处理
img = augment_hsv(img, h_gain=hyp["hsv_h"], s_gain=hyp["hsv_s"], v_gain=hyp["hsv_v"])
# 在进行仿射变换之后会忽略一些边界框,如果没有边界框信息就可以跳过了,如果有则进行处理
nL = len(labels) # number of labels
if nL:
# convert xyxy to xywh
labels[:, 1:5] = xyxy2xywh(labels[:, 1:5])
# Normalize coordinates 0-1: 归一化处理
labels[:, [2, 4]] /= img.shape[0] # height
labels[:, [1, 3]] /= img.shape[1] # width
# 进行随机水平翻转也竖直翻转
if self.augment:
# random left-right flip
lr_flip = True # 随机水平翻转
if lr_flip and random.random() < 0.5:
img = np.fliplr(img)
if nL:
labels[:, 1] = 1 - labels[:, 1] # 1 - x_center
# random up-down flip
ud_flip = False
if ud_flip and random.random() < 0.5:
img = np.flipud(img) # 随机竖直翻转
if nL:
labels[:, 2] = 1 - labels[:, 2] # 1 - y_center
# 判断翻转后是否还有边界框信息, 并进行格式转换
labels_out = torch.zeros((nL, 6)) # nL: number of labels
if nL:
labels_out[:, 1:] = torch.from_numpy(labels)
# Convert BGR to RGB, and HWC to CHW(3x512x512)
img = img[:, :, ::-1].transpose(2, 0, 1)
img = np.ascontiguousarray(img) # 内存连续
return torch.from_numpy(img), labels_out, self.img_files[index], shapes, index
所以在yolov3spp中,提供的数据增强方式有mosaic,仿射变换(随机旋转、平移、缩放、错切),hsv色彩增强。但是在yolov3spp中,一般训练过程会是一个mosaic,这会包含了随机旋转、平移、缩放、错切,此时还可以设置是否使用hsv色彩增强以及上下随机翻转。而在推理过程中,就不会使用mosaic,但是可以选择是否使用除mosaic外的其他数据增强方式。
参考资料: