YOLOV5代码精读之数据增强(augmentations.py)

一、超参数(Hyperparameters)

超参数(Hyperparameters)是在机器学习算法中,开始学习过程之前需要设置的参数,而不是通过训练过程学习的参数。这些参数定义了模型训练的方式和过程,对模型的性能有重要影响。超参数的选择通常依赖于问题的性质、数据的特性以及实验者的经验。

# YOLOv5 🚀 by Ultralytics, GPL-3.0 license
# Hyperparameters for VOC training
# python train.py --batch 128 --weights yolov5m6.pt --data VOC.yaml --epochs 50 --img 512 --hyp hyp.scratch-med.yaml --evolve
# See Hyperparameter Evolution tutorial for details https://github.com/ultralytics/yolov5#tutorials

# YOLOv5 Hyperparameter Evolution Results
# Best generation: 467
# Last generation: 996
#    metrics/precision,       metrics/recall,      metrics/mAP_0.5, metrics/mAP_0.5:0.95,         val/box_loss,         val/obj_loss,         val/cls_loss
#              0.87729,              0.85125,              0.91286,              0.72664,            0.0076739,            0.0042529,            0.0013865

lr0: 0.00334
lrf: 0.15135
momentum: 0.74832
weight_decay: 0.00025
warmup_epochs: 3.3835
warmup_momentum: 0.59462
warmup_bias_lr: 0.18657
box: 0.02
cls: 0.21638
cls_pw: 0.5
obj: 0.51728
obj_pw: 0.67198
iou_t: 0.2
anchor_t: 3.3744
fl_gamma: 0.0
hsv_h: 0.01041
hsv_s: 0.54703
hsv_v: 0.27739
degrees: 0.0
translate: 0.04591
scale: 0.75544
shear: 0.0
perspective: 0.0
flipud: 0.0
fliplr: 0.5
mosaic: 0.85834
mixup: 0.04266
copy_paste: 0.0
anchors: 3.412

 1.1 超参数分类

1. 优化器相关参数

  • lr0:初始学习率。这是模型训练开始时的学习率,其值通常根据问题的复杂度和数据集的特点来选择。在YOLOv5中,使用SGD优化器时,该值可能设置为0.01左右;如果使用Adam优化器,则可能更小,如0.001。

  • lrf:最终OneCycleLR学习率。这个参数用于计算OneCycle学习率调度策略中的最终学习率,它是初始学习率(lr0)乘以lrf得到的。OneCycleLR是一种学习率调度策略,它允许学习率在训练过程中先增加后减少,有助于模型更快地收敛。

  • momentum:SGD动量/Adam beta1。动量可以加速SGD(随机梯度下降)的收敛速度,帮助跳出局部最优解。对于Adam优化器,它代表了一阶矩的指数衰减率,控制了参数更新时历史梯度的影响程度。

  • weight_decay:优化器权重衰减。这个参数控制了模型参数的L2正则化项的大小,有助于防止过拟合。较大的weight_decay值会增加正则化强度,但过大可能导致模型欠拟合。

2. 训练过程相关参数

  • warmup_epochs:热身阶段轮数。在训练开始阶段,逐渐增加学习率以避免训练初期的不稳定性。这个参数指定了热身阶段的轮数,有助于模型更快地收敛到稳定状态。

  • warmup_momentum:热身阶段初始动量。与warmup_epochs类似,这个参数指定了热身阶段的初始动量值,有助于模型在热身阶段更好地适应学习率的变化。

  • warmup_bias_lr:热身阶段初始偏置学习率。偏置(bias)是神经网络中的一个参数,与权重不同,它通常不需要正则化。这个参数指定了热身阶段偏置参数的初始学习率。

3. 损失函数相关参数

  • box:边界框损失增益。这个参数控制了边界框损失在总损失中的权重,可以根据需要调整边界框损失的相对重要性。

  • cls:分类损失增益。这个参数控制了分类损失在总损失中的权重,即预测物体类别的损失的相对重要性。

  • cls_pw:分类BCELoss正例权重。在分类损失中,正例和负例的权重可能不平衡,这个参数用于平衡分类损失中正例和负例的权重,特别是在处理类别不平衡的数据集时。

  • obj:目标检测损失增益。这个参数控制了目标检测损失(即预测物体边界框的损失)在总损失中的权重。

  • obj_pw:目标检测BCELoss正例权重。与cls_pw类似,这个参数用于平衡目标检测损失中正例和负例的权重。

4. 数据增强相关参数

  • hsv_h, hsv_s, hsv_v:图像HSV色彩增强。这些参数控制了对输入图像进行色调(Hue)、饱和度(Saturation)和明度(Value)的随机增强,有助于增加数据的多样性。

  • degrees(图像旋转的角度), translate(图像平移), scale(图像缩放), shear(图像剪切), perspective(图像透视变换:图像仿射变换参数。这些参数控制了对输入图像进行随机仿射变换(如旋转、平移、缩放、剪切和透视变换)的程度,以增加数据的多样性和模型的鲁棒性。

  • flipud(上下翻转的概率), fliplr(左右翻转的概率), mosaic(马赛克数据增强的概率), mixup(混合训练样本的概率), copy_paste(拷贝粘贴增强的概率:图像增强策略。这些参数控制了是否对输入图像进行上下翻转、左右翻转、拼接(Mosaic)、混合(Mixup)和分段复制粘贴(Copy Paste)等增强操作,以进一步增加数据的多样性和模型的鲁棒性。

5. 其他参数

  • iou_t:IoU训练阈值。用于确定预测边界框与真实边界框之间的重叠度阈值,当预测框与真实框的重叠度高于该阈值时,才认为预测框正确。

  • anchor_t:锚点倍数阈值。用于确定哪些边界框会被用作预测目标,帮助筛选与真实目标大小相近的锚点框。

  • fl_gamma:焦点损失(Focal Loss)gamma。用于调整焦点损失函数中的难易样本的权重,帮助模型更加关注难以分类的样本。

1.2 hyp.Objects365.yaml,hyp.VOC.yaml,hyp.scratch-low.yaml ,hyp.scratch-med.yaml与hyp.scratch-high.yaml 区别及应用场景

hyp.Objects365.yamlhyp.VOC.yamlhyp.scratch-low.yamlhyp.scratch-med.yamlhyp.scratch-high.yaml是YOLOv5目标检测模型中用于配置训练超参数的YAML文件。这些文件在训练过程中起着至关重要的作用,它们定义了如学习率、动量、权重衰减、数据增强策略等关键参数。以下是这些文件之间的区别及应用场景:

1. hyp.Objects365.yaml

应用场景

  • 专门用于Objects365数据集的训练。Objects365是一个包含大量类别和图像的大型数据集,适合需要广泛类别覆盖和高精度检测的场景。

特点

  • 该配置文件可能针对Objects365数据集的特性和规模进行了优化,包括学习率、数据增强策略等,以最大化在该数据集上的性能。

2. hyp.VOC.yaml

应用场景

  • 专门用于VOC(Visual Object Classes)数据集的训练。VOC是一个较为经典的目标检测数据集,包含有限数量的类别和图像,适合作为基准测试或小规模项目的训练集。

特点

  • 该配置文件可能针对VOC数据集的特性和规模进行了调整,如减少数据增强的强度或调整学习率等,以适应数据集的特点。

3. hyp.scratch-low.yaml

应用场景

  • 适用于从头开始训练较小型号(如YOLOv5n、YOLOv5s)的模型,且数据增强需求较低的场景。

特点

  • 初始学习率、动量、权重衰减等参数设置较为保守,数据增强策略(如图像旋转、翻转、缩放等)的强度也较低,以减少对小型模型的过度训练风险。

4. hyp.scratch-med.yaml

应用场景

  • 适用于从头开始训练中等型号(如YOLOv5m)的模型,数据增强需求适中的场景。

特点

  • 相较于hyp.scratch-low.yaml,该配置文件在数据增强策略上有所增强,同时学习率、动量等参数也可能有所调整,以更好地平衡训练速度和模型性能。

5. hyp.scratch-high.yaml

应用场景

  • 适用于从头开始训练较大型号(如YOLOv5l、YOLOv5x)的模型,且数据增强需求较高的场景。

特点

  • 数据增强策略最为激进,包括更多的图像变换和更高的变换概率,以帮助模型学习到更多的特征并提升泛化能力。同时,学习率、动量等参数也可能设置得更为激进,以加速模型的训练过程。

这些YAML文件的主要区别在于它们针对不同的应用场景和模型型号进行了优化。选择哪个配置文件取决于你的具体需求,包括数据集的特性、模型的大小以及你希望达到的训练效果。在实际应用中,你可能需要根据自己的需求对配置文件中的参数进行微调,以获得最佳的训练效果。

二、 Mosaic增强

YOLOV5中的Mosaic增强是一种数据增强技术,它在目标检测任务中用于增加数据集的多样性和复杂性,从而提高模型的泛化能力和训练效果。

Mosaic简单的说就是把四张训练图片缩放拼成一张图,Mosaic有利于提升小目标的检测,这是因为一般在数据集中小目标在图片中分布不均匀,这导致在常规的训练中小目标的学习总是不太充分。使用mosaic数据增强后,在遍历每个张图片包含了四张图片具有小目标的可能性就很大了,同时,每张图都有不同程度的缩小,即使没有小目标,通过缩小,原来的目标尺寸也更接近小目标的大小,这对模型学习小目标很有利。

2.1、Mosaic增强的基本原理

Mosaic增强是在CutMix数据增强的基础上进行改进而来。与CutMix仅使用两张图片拼接不同,Mosaic增强采用了四张图片,并按照随机缩放、随机裁剪和随机排布的方式进行拼接。具体步骤如下:

  1. 随机产生拼接中心点坐标:中心点坐标范围在图像尺寸的一半到三倍图像尺寸的一半之间(例如,若图像尺寸为640x640,则中心点坐标范围为[320, 960]x[320, 960])。
  2. 选择四张图像:从数据集中随机选择四张图像,准备进行拼接。
  3. 计算并放置图像:将这四张图像依次放置在新图像的左上方、右上方、左下方和右下方。根据中心点和图像尺寸,计算每张图像在新图像上的坐标,并截取相应的图像区域进行拼接。
  4. 调整图像大小:最后,通过resize操作将拼接后的图像调整回原始图像大小(如640x640),以便输入到模型中进行训练。

2.2、Mosaic增强的优势

  1. 丰富数据集:通过拼接四张不同的图像,Mosaic增强可以生成大量新的训练样本,从而丰富数据集,提高模型的泛化能力。
  2. 提升训练速度:由于Mosaic增强是在一个批次中同时处理四张图像,因此可以显著提高模型的训练速度。
  3. 降低内存需求:与单独处理四张图像相比,Mosaic增强在拼接后只需要处理一张图像,从而降低了模型的内存需求。
  4. 增强小目标检测能力:由于拼接后的图像中包含了更多的上下文信息,因此有助于提升模型对小目标的检测能力。

2.3、Mosaic增强实现原理解析

Mosaic数据增强主要思想是将多张图片随机拼接成一张大图,增加训练数据的多样性。我们以四张图片拼接成一张图举例,如下图所示:


第一步:创建H*W*C=1280*1280*3的画布,代码如下:

import numpy as np
s=640
# 创建画布H*W*C=1280*1280*3
img4 = np.full((s * 2, s * 2, 3), 114, dtype=np.uint8) 

 第二步:在画布的中心区域(x取值320~960,y取值320~960)随机产生一个中心点,如下图: 

import numpy as np
import random

# 定义变量
s=640
mosaic_border=[-320,-320]
# 创建画布H*W*C=1280*1280*3
img4 = np.full((s * 2, s * 2, 3), 114, dtype=np.uint8)
# 画布中心区域产生中心点
yc, xc = [int(random.uniform(-x, 2 * s + x)) for x in mosaic_border] 

第三步:将图片1放置在画布的左上区域

当画布左上区域覆盖图片1时,会保留图片1整体,如下图: 

当图片1覆盖画布左上区域时,会截取图片1,如下图: 

 坐标变换代码:

x1a, y1a, x2a, y2a = max(xc - w, 0), max(yc - h, 0), xc, yc
x1b, y1b, x2b, y2b = w - (x2a - x1a), h - (y2a - y1a), w, h 
import numpy as np
import random
import cv2
import matplotlib.pyplot as plt

# 读取图片
def load_image(img_files,index):
    '''
    根据index读取图片
    '''
    path=img_files[index]
    img=cv2.imread(path)
    # 图片原始H,W
    h0,w0=img.shape[:2]
    # 较长的边缩放到640,另一边也等比例缩放
    r=640/max(h0,w0)
    if r!=1:
        img = cv2.resize(img, (int(w0 * r), int(h0 * r)), interpolation=cv2.INTER_LINEAR)
    return img, (h0, w0), img.shape[:2]

# 显示图片
def show(img):
    img=cv2.cvtColor(img,cv2.COLOR_BGR2RGB)
    plt.figure()
    plt.subplot(1,1,1)
    plt.imshow(img)
    plt.show()
    plt.pause(5)

if __name__=="__main__":
    # 定义变量
    s=640
    mosaic_border=[-320,-320]
    # 创建画布H*W*C=1280*1280*3
    img4 = np.full((s * 2, s * 2, 3), 114, dtype=np.uint8)
    # 画布中心区域产生中心点
    yc, xc = [int(random.uniform(-x, 2 * s + x)) for x in mosaic_border]

    # 四张图片索引
    indices=[0,1,2,3]
    # 四张图片的路径
    img_files=["top_left.jpg","top_right.jpg","down_left.jpg","down_right.jpg"]
    # Mosaic数据增强
    for i, index in enumerate(indices):
        img, _, (h, w) = load_image(img_files, index)
        if i == 0:  # top left
            x1a, y1a, x2a, y2a = max(xc - w, 0), max(yc - h, 0), xc, yc
            x1b, y1b, x2b, y2b = w - (x2a - x1a), h - (y2a - y1a), w, h
        # 将图片放置在画布相应位置
        img4[y1a:y2a, x1a:x2a] = img[y1b:y2b, x1b:x2b]
        # 显示画布
        show(img4)
        break 

第四步:将图片2放置在画布的右上区域

当画布右上区域覆盖图片2时,会保留图片2整体,如下图:


当图片2覆盖画布右上区域时,会截取图片2,如下图:


坐标变换代码: 

x1a, y1a, x2a, y2a = xc, max(yc - h, 0), min(xc + w, s * 2), yc
x1b, y1b, x2b, y2b = 0, h - (y2a - y1a), min(w, x2a - x1a), h 
import numpy as np
import random
import cv2
import matplotlib.pyplot as plt

# 读取图片
def load_image(img_files,index):
    '''
    根据index读取图片
    '''
    path=img_files[index]
    img=cv2.imread(path)
    # 图片原始H,W
    h0,w0=img.shape[:2]
    # 较长的边缩放到640,另一边也等比例缩放
    r=640/max(h0,w0)
    if r!=1:
        img = cv2.resize(img, (int(w0 * r), int(h0 * r)), interpolation=cv2.INTER_LINEAR)
    return img, (h0, w0), img.shape[:2]

# 显示图片
def show(img):
    img=cv2.cvtColor(img,cv2.COLOR_BGR2RGB)
    plt.figure()
    plt.subplot(1,1,1)
    plt.imshow(img)
    plt.show()
    plt.pause(5)

if __name__=="__main__":
    # 定义变量
    s=640
    mosaic_border=[-320,-320]
    # 创建画布H*W*C=1280*1280*3
    img4 = np.full((s * 2, s * 2, 3), 114, dtype=np.uint8)
    # 画布中心区域产生中心点
    yc, xc = [int(random.uniform(-x, 2 * s + x)) for x in mosaic_border]

    # 四张图片索引
    indices=[0,1,2,3]
    # 四张图片的路径
    img_files=["top_left.jpg","top_right.jpg","down_left.jpg","down_right.jpg"]
    # Mosaic数据增强
    for i, index in enumerate(indices):
        img, _, (h, w) = load_image(img_files, index)
        if i == 0:  # top left
            x1a, y1a, x2a, y2a = max(xc - w, 0), max(yc - h, 0), xc, yc
            x1b, y1b, x2b, y2b = w - (x2a - x1a), h - (y2a - y1a), w, h
        elif i == 1:  # top right
            x1a, y1a, x2a, y2a = xc, max(yc - h, 0), min(xc + w, s * 2), yc
            x1b, y1b, x2b, y2b = 0, h - (y2a - y1a), min(w, x2a - x1a), h
        # 将图片放置在画布相应位置
        img4[y1a:y2a, x1a:x2a] = img[y1b:y2b, x1b:x2b]
        if i==1:
            break
    # 显示画布
    show(img4) 

 

第五步:将图片3放置在画布的左下区域

当画布左下区域覆盖图片3时,会保留图片3整体,如下图:


当图片3覆盖画布左下区域时,会截取图片3,如下图:


坐标变换代码: 

x1a, y1a, x2a, y2a = max(xc - w, 0), yc, xc, min(s * 2, yc + h)
x1b, y1b, x2b, y2b = w - (x2a - x1a), 0, w, min(y2a - y1a, h) 
import numpy as np
import random
import cv2
import matplotlib.pyplot as plt

# 读取图片
def load_image(img_files,index):
    '''
    根据index读取图片
    '''
    path=img_files[index]
    img=cv2.imread(path)
    # 图片原始H,W
    h0,w0=img.shape[:2]
    # 较长的边缩放到640,另一边也等比例缩放
    r=640/max(h0,w0)
    if r!=1:
        img = cv2.resize(img, (int(w0 * r), int(h0 * r)), interpolation=cv2.INTER_LINEAR)
    return img, (h0, w0), img.shape[:2]

# 显示图片
def show(img):
    img=cv2.cvtColor(img,cv2.COLOR_BGR2RGB)
    plt.figure()
    plt.subplot(1,1,1)
    plt.imshow(img)
    plt.show()
    plt.pause(5)

if __name__=="__main__":
    # 定义变量
    s=640
    mosaic_border=[-320,-320]
    # 创建画布H*W*C=1280*1280*3
    img4 = np.full((s * 2, s * 2, 3), 114, dtype=np.uint8)
    # 画布中心区域产生中心点
    yc, xc = [int(random.uniform(-x, 2 * s + x)) for x in mosaic_border]

    # 四张图片索引
    indices=[0,1,2,3]
    # 四张图片的路径
    img_files=["top_left.jpg","top_right.jpg","down_left.jpg","down_right.jpg"]
    # Mosaic数据增强
    for i, index in enumerate(indices):
        img, _, (h, w) = load_image(img_files, index)
        if i == 0:  # top left
            x1a, y1a, x2a, y2a = max(xc - w, 0), max(yc - h, 0), xc, yc
            x1b, y1b, x2b, y2b = w - (x2a - x1a), h - (y2a - y1a), w, h
        elif i == 1:  # top right
            x1a, y1a, x2a, y2a = xc, max(yc - h, 0), min(xc + w, s * 2), yc
            x1b, y1b, x2b, y2b = 0, h - (y2a - y1a), min(w, x2a - x1a), h
        elif i == 2:  # bottom left
            x1a, y1a, x2a, y2a = max(xc - w, 0), yc, xc, min(s * 2, yc + h)
            x1b, y1b, x2b, y2b = w - (x2a - x1a), 0, w, min(y2a - y1a, h)
        # 将图片放置在画布相应位置
        img4[y1a:y2a, x1a:x2a] = img[y1b:y2b, x1b:x2b]
        if i==2:
            break
    # 显示画布
    show(img4) 

第六步:将图片4放置在画布的右下区域

当画布右下区域覆盖图片4时,会保留图片4整体,如下图:


当图片4覆盖画布右下区域时,会截取图片4,如下图:


坐标变换代码:

import numpy as np
import random
import cv2
import matplotlib.pyplot as plt

# 读取图片
def load_image(img_files,index):
    '''
    根据index读取图片
    '''
    path=img_files[index]
    img=cv2.imread(path)
    # 图片原始H,W
    h0,w0=img.shape[:2]
    # 较长的边缩放到640,另一边也等比例缩放
    r=640/max(h0,w0)
    if r!=1:
        img = cv2.resize(img, (int(w0 * r), int(h0 * r)), interpolation=cv2.INTER_LINEAR)
    return img, (h0, w0), img.shape[:2]

# 显示图片
def show(img):
    img=cv2.cvtColor(img,cv2.COLOR_BGR2RGB)
    plt.figure()
    plt.subplot(1,1,1)
    plt.imshow(img)
    plt.show()
    plt.pause(5)

if __name__=="__main__":
    # 定义变量
    s=640
    mosaic_border=[-320,-320]
    # 创建画布H*W*C=1280*1280*3
    img4 = np.full((s * 2, s * 2, 3), 114, dtype=np.uint8)
    # 画布中心区域产生中心点
    yc, xc = [int(random.uniform(-x, 2 * s + x)) for x in mosaic_border]

    # 四张图片索引
    indices=[0,1,2,3]
    # 四张图片的路径
    img_files=["top_left.jpg","top_right.jpg","down_left.jpg","down_right.jpg"]
    # Mosaic数据增强
    for i, index in enumerate(indices):
        img, _, (h, w) = load_image(img_files, index)
        if i == 0:  # top left
            x1a, y1a, x2a, y2a = max(xc - w, 0), max(yc - h, 0), xc, yc
            x1b, y1b, x2b, y2b = w - (x2a - x1a), h - (y2a - y1a), w, h
        elif i == 1:  # top right
            x1a, y1a, x2a, y2a = xc, max(yc - h, 0), min(xc + w, s * 2), yc
            x1b, y1b, x2b, y2b = 0, h - (y2a - y1a), min(w, x2a - x1a), h
        elif i == 2:  # bottom left
            x1a, y1a, x2a, y2a = max(xc - w, 0), yc, xc, min(s * 2, yc + h)
            x1b, y1b, x2b, y2b = w - (x2a - x1a), 0, w, min(y2a - y1a, h)
        elif i == 3:  # bottom right
            x1a, y1a, x2a, y2a = xc, yc, min(xc + w, s * 2), min(s * 2, yc + h)
            x1b, y1b, x2b, y2b = 0, 0, min(w, x2a - x1a), min(y2a - y1a, h)
        # 将图片放置在画布相应位置
        img4[y1a:y2a, x1a:x2a] = img[y1b:y2b, x1b:x2b]
    # 显示画布
    show(img4) 

第七步:对目标框标签进行调整,使其相对于画布的位置和大小保持不变。

单张图像放置到画布后,X坐标和Y坐标变化量为: 

padw = x1a - x1b
padh = y1a - y1b 

我们只需让标签数据加上相应的变化量即可,YOLOv5标签格式为:


标签转换过程如下图:


标签转换代码:

labels[:, 1] = w * (x[:, 1] - x[:, 3] / 2) + padw
labels[:, 2] = h * (x[:, 2] - x[:, 4] / 2) + padh
labels[:, 3] = w * (x[:, 1] + x[:, 3] / 2) + padw
labels[:, 4] = h * (x[:, 2] + x[:, 4] / 2) + padh 

import numpy as np
import random
import cv2
import matplotlib.pyplot as plt

# 读取图片
def load_image(img_files,index):
    '''
    根据index读取图片
    '''
    path=img_files[index]
    img=cv2.imread(path)
    # 图片原始H,W
    h0,w0=img.shape[:2]
    # 较长的边缩放到640,另一边也等比例缩放
    r=640/max(h0,w0)
    if r!=1:
        img = cv2.resize(img, (int(w0 * r), int(h0 * r)), interpolation=cv2.INTER_LINEAR)
    return img, (h0, w0), img.shape[:2]

# 显示图片
def show(img):
    img=cv2.cvtColor(img,cv2.COLOR_BGR2RGB)
    plt.figure()
    plt.subplot(1,1,1)
    plt.imshow(img)
    plt.show()
    plt.pause(5)

# 标签信息
# class,x,y,,w,h
top_left=np.array([[0,0.432,0.584,0.41600000000000004,0.5439999999999999]],dtype=np.float32)
top_right=np.array([[2,0.373,0.4933333333333333,0.17,0.5493333333333333],
                    [2,0.542,0.48133333333333334,0.22,0.616],
                    [3,0.514,0.7146666666666667,0.5720000000000001,0.448],
                    [4,0.716,0.432,0.352,0.21866666666666665]],dtype=np.float32)
down_left=np.array([[0,0.4758064516129032,0.513,0.9475806451612903,0.97]],dtype=np.float32)
down_right=np.array([[1,0.498,0.5026666666666666,0.544,0.37066666666666664]],dtype=np.float32)
total_labels=[top_left,top_right,down_left,down_right]

if __name__=="__main__":
    # 定义变量
    s=640
    mosaic_border=[-320,-320]
    # 创建画布H*W*C=1280*1280*3
    img4 = np.full((s * 2, s * 2, 3), 114, dtype=np.uint8)
    # 画布中心区域产生中心点
    yc, xc = [int(random.uniform(-x, 2 * s + x)) for x in mosaic_border]

    # 四张图片索引
    indices=[0,1,2,3]
    # 四张图片的路径
    img_files=["top_left.jpg","top_right.jpg","down_left.jpg","down_right.jpg"]
    # Mosaic数据增强
    # 存放四张图像的标签
    labels4=[]
    for i, index in enumerate(indices):
        img, _, (h, w) = load_image(img_files, index)
        if i == 0:  # top left
            x1a, y1a, x2a, y2a = max(xc - w, 0), max(yc - h, 0), xc, yc
            x1b, y1b, x2b, y2b = w - (x2a - x1a), h - (y2a - y1a), w, h
        elif i == 1:  # top right
            x1a, y1a, x2a, y2a = xc, max(yc - h, 0), min(xc + w, s * 2), yc
            x1b, y1b, x2b, y2b = 0, h - (y2a - y1a), min(w, x2a - x1a), h
        elif i == 2:  # bottom left
            x1a, y1a, x2a, y2a = max(xc - w, 0), yc, xc, min(s * 2, yc + h)
            x1b, y1b, x2b, y2b = w - (x2a - x1a), 0, w, min(y2a - y1a, h)
        elif i == 3:  # bottom right
            x1a, y1a, x2a, y2a = xc, yc, min(xc + w, s * 2), min(s * 2, yc + h)
            x1b, y1b, x2b, y2b = 0, 0, min(w, x2a - x1a), min(y2a - y1a, h)
        # 将图片放置在画布相应位置
        img4[y1a:y2a, x1a:x2a] = img[y1b:y2b, x1b:x2b]
        # 调整标签
        padw = x1a - x1b
        padh = y1a - y1b
        x=total_labels[index]
        labels=x.copy()
        if x.size > 0:  # Normalized xywh to pixel xyxy format
            labels[:, 1] = w * (x[:, 1] - x[:, 3] / 2) + padw
            labels[:, 2] = h * (x[:, 2] - x[:, 4] / 2) + padh
            labels[:, 3] = w * (x[:, 1] + x[:, 3] / 2) + padw
            labels[:, 4] = h * (x[:, 2] + x[:, 4] / 2) + padh
        labels4.append(labels)
    if len(labels4):
        # 将四张图像的标签进行拼接
        labels4 = np.concatenate(labels4, 0)
        # 裁剪标签,保证数据取值在0~2*s
        np.clip(labels4[:, 1:], 0, 2 * s, out=labels4[:, 1:])
    # 标签尺寸为n*5,n表示bbox个数
    print(labels4.shape)
    # 将bbox绘制在画布上
    for i in range(len(labels4)):
        box=labels4[i]
        # 左上角,右下角
        c1,c2=(int(box[1]),int(box[2])),(int(box[3]),int(box[4]))
        cv2.rectangle(img4,c1,c2,(0,255,0),thickness=4,lineType=cv2.LINE_AA)
    # 显示画布
    show(img4) 

 

 2.4 YOLOV5里load_mosaic解析

  • 把四张图像拼接成一个马赛克图,随机从数据集中选择图像并填充对应的区域,适用于增强训练数据。
  • 这个模块就是很有名的mosaic增强模块,几乎训练的时候都会用它,可以显著的提高小样本的mAP。

    代码是数据增强里面最难的, 也是最有价值的,mosaic是非常非常有用的数据增强trick, 一定要熟练掌握。

 def load_mosaic(self, index):
        # YOLOv5 4-mosaic loader. Loads 1 image + 3 random images into a 4-image mosaic
        labels4, segments4 = [], []
        s = self.img_size
        yc, xc = (int(random.uniform(-x, 2 * s + x)) for x in self.mosaic_border)  # mosaic center x, y
        indices = [index] + random.choices(self.indices, k=3)  # 3 additional image indices
        random.shuffle(indices)
        for i, index in enumerate(indices):
            # Load image
            img, _, (h, w) = self.load_image(index)

            # place img in img4
            if i == 0:  # top left
                img4 = np.full((s * 2, s * 2, img.shape[2]), 114, dtype=np.uint8)  # base image with 4 tiles
                x1a, y1a, x2a, y2a = max(xc - w, 0), max(yc - h, 0), xc, yc  # xmin, ymin, xmax, ymax (large image)
                x1b, y1b, x2b, y2b = w - (x2a - x1a), h - (y2a - y1a), w, h  # xmin, ymin, xmax, ymax (small image)
            elif i == 1:  # top right
                x1a, y1a, x2a, y2a = xc, max(yc - h, 0), min(xc + w, s * 2), yc
                x1b, y1b, x2b, y2b = 0, h - (y2a - y1a), min(w, x2a - x1a), h
            elif i == 2:  # bottom left
                x1a, y1a, x2a, y2a = max(xc - w, 0), yc, xc, min(s * 2, yc + h)
                x1b, y1b, x2b, y2b = w - (x2a - x1a), 0, w, min(y2a - y1a, h)
            elif i == 3:  # bottom right
                x1a, y1a, x2a, y2a = xc, yc, min(xc + w, s * 2), min(s * 2, yc + h)
                x1b, y1b, x2b, y2b = 0, 0, min(w, x2a - x1a), min(y2a - y1a, h)

            img4[y1a:y2a, x1a:x2a] = img[y1b:y2b, x1b:x2b]  # img4[ymin:ymax, xmin:xmax]
            padw = x1a - x1b
            padh = y1a - y1b

            # Labels
            labels, segments = self.labels[index].copy(), self.segments[index].copy()
            if labels.size:
                labels[:, 1:] = xywhn2xyxy(labels[:, 1:], w, h, padw, padh)  # normalized xywh to pixel xyxy format
                segments = [xyn2xy(x, w, h, padw, padh) for x in segments]
            labels4.append(labels)
            segments4.extend(segments)

        # Concat/clip labels
        labels4 = np.concatenate(labels4, 0)
        for x in (labels4[:, 1:], *segments4):
            np.clip(x, 0, 2 * s, out=x)  # clip when using random_perspective()
        # img4, labels4 = replicate(img4, labels4)  # replicate

        # Augment
        img4, labels4, segments4 = copy_paste(img4, labels4, segments4, p=self.hyp['copy_paste'])
        img4, labels4 = random_perspective(img4,
                                           labels4,
                                           segments4,
                                           degrees=self.hyp['degrees'],
                                           translate=self.hyp['translate'],
                                           scale=self.hyp['scale'],
                                           shear=self.hyp['shear'],
                                           perspective=self.hyp['perspective'],
                                           border=self.mosaic_border)  # border to remove

        return img4, labels4

这段代码定义了一个名为 load_mosaic 的方法,主要用于创建一个“马赛克”图像以便在训练时增强数据。下面逐步分解并详细解释这段代码。

  1. 方法定义及初始化

    def load_mosaic(self, index):
    

    该方法接收一个参数 index,这是当前图像在数据集中索引。

  2. 初始化变量

    labels4, segments4 = [], []
    s = self.img_size
    
    • labels4 和 segments4 用于存储合并后的标签和分段信息。
    • s 是图像的尺寸,用于后续创建合成图像的大小。
  3. 确定马赛克中心的坐标

    yc, xc = (int(random.uniform(-x, 2 * s + x)) for x in self.mosaic_border)
    
    • 随机生成马赛克图像的中心坐标,mosaic_border 定义了边界。
  4. 选择要加载的图像索引

    indices = [index] + random.choices(self.indices, k=3)
    random.shuffle(indices)
    
    • indices 包含当前图像的索引和3个随机选中的索引,目的是获取总共4张图像。
    • 打乱这些索引以便随机放置图像。
  5. 加载并放置图像

    for i, index in enumerate(indices):
        img, _, (h, w) = self.load_image(index)
    

    在循环中,对于每个 index,加载对应的图像。

    • 根据索引加载图像及其原始的高度和宽度 h 和 w
  6. 计算放置坐标

    • 根据当前图像的位置(左上、右上、左下、右下),计算在马赛克图像中应放置图像的坐标:
    if i == 0:  # top left
        # 计算坐标
    elif i == 1:  # top right
        # 计算坐标
    elif i == 2:  # bottom left
        # 计算坐标
    elif i == 3:  # bottom right
        # 计算坐标
    
    • 这些坐标计算使用了 max 和 min 函数,以确保图像不会超出矩阵边界。
  7. 图像合成

    img4[y1a:y2a, x1a:x2a] = img[y1b:y2b, x1b:x2b]
    
    • 将当前图像按计算的坐标放置到马赛克图像中。
  8. 调整标签和分段

    labels, segments = self.labels[index].copy(), self.segments[index].copy()
    if labels.size:
        labels[:, 1:] = xywhn2xyxy(labels[:, 1:], w, h, padw, padh)
        segments = [xyn2xy(x, w, h, padw, padh) for x in segments]
    labels4.append(labels)
    segments4.extend(segments)
    
    • 对应于每个图像,调整其标签和分段的坐标,适应新的合成图像的位置。
    • 使用辅助函数 xywhn2xyxy 和 xyn2xy 进行坐标转换。
  9. 合并和裁剪标签

    labels4 = np.concatenate(labels4, 0)
    for x in (labels4[:, 1:], *segments4):
        np.clip(x, 0, 2 * s, out=x)
    
    • 所有标签和分段信息合并为 labels4
    • 使用 np.clip 确保标签和分段坐标不会超出图像的边缘。
  10. 数据增强

    img4, labels4, segments4 = copy_paste(img4, labels4, segments4, p=self.hyp['copy_paste'])
    img4, labels4 = random_perspective(img4, labels4, segments4, degrees=self.hyp['degrees'], translate=self.hyp['translate'], scale=self.hyp['scale'], shear=self.hyp['shear'], perspective=self.hyp['perspective'], border=self.mosaic_border)
    
    • 使用 copy_paste 进行随机复制粘贴增强。
    • 使用 random_perspective 增加随机透视变换效果,以不同的角度和比例对图像进行调整。
  11. 返回结果

    return img4, labels4
    
    • 返回合成好的马赛克图像和相应的标签。

load_mosaic 方法主要功能是从数据集中加载一张当前图像及三张随机图像,合成一个马赛克图像。它通过随机中心坐标、计算合适的放置位置、调整标签和进行数据增强来优化图像并增强模型训练的表现。通过这种方式,模型能够在不同的图像组合中学习到更多的特征,从而提高泛化能力。

 2.5 YOLOV5里load_mosaic9解析

  • 这个模块是作者的实验模块,将九张图片拼接在一张马赛克图像中。总体代码流程和load_mosaic4几乎一样,看懂了load_mosaic4再看这个就很简单了、
    def load_mosaic9(self, index):
        # YOLOv5 9-mosaic loader. Loads 1 image + 8 random images into a 9-image mosaic
        labels9, segments9 = [], []
        s = self.img_size
        indices = [index] + random.choices(self.indices, k=8)  # 8 additional image indices
        random.shuffle(indices)
        hp, wp = -1, -1  # height, width previous
        for i, index in enumerate(indices):
            # Load image
            img, _, (h, w) = self.load_image(index)

            # place img in img9
            if i == 0:  # center
                img9 = np.full((s * 3, s * 3, img.shape[2]), 114, dtype=np.uint8)  # base image with 4 tiles
                h0, w0 = h, w
                c = s, s, s + w, s + h  # xmin, ymin, xmax, ymax (base) coordinates
            elif i == 1:  # top
                c = s, s - h, s + w, s
            elif i == 2:  # top right
                c = s + wp, s - h, s + wp + w, s
            elif i == 3:  # right
                c = s + w0, s, s + w0 + w, s + h
            elif i == 4:  # bottom right
                c = s + w0, s + hp, s + w0 + w, s + hp + h
            elif i == 5:  # bottom
                c = s + w0 - w, s + h0, s + w0, s + h0 + h
            elif i == 6:  # bottom left
                c = s + w0 - wp - w, s + h0, s + w0 - wp, s + h0 + h
            elif i == 7:  # left
                c = s - w, s + h0 - h, s, s + h0
            elif i == 8:  # top left
                c = s - w, s + h0 - hp - h, s, s + h0 - hp

            padx, pady = c[:2]
            x1, y1, x2, y2 = (max(x, 0) for x in c)  # allocate coords

            # Labels
            labels, segments = self.labels[index].copy(), self.segments[index].copy()
            if labels.size:
                labels[:, 1:] = xywhn2xyxy(labels[:, 1:], w, h, padx, pady)  # normalized xywh to pixel xyxy format
                segments = [xyn2xy(x, w, h, padx, pady) for x in segments]
            labels9.append(labels)
            segments9.extend(segments)

            # Image
            img9[y1:y2, x1:x2] = img[y1 - pady:, x1 - padx:]  # img9[ymin:ymax, xmin:xmax]
            hp, wp = h, w  # height, width previous

        # Offset
        yc, xc = (int(random.uniform(0, s)) for _ in self.mosaic_border)  # mosaic center x, y
        img9 = img9[yc:yc + 2 * s, xc:xc + 2 * s]

        # Concat/clip labels
        labels9 = np.concatenate(labels9, 0)
        labels9[:, [1, 3]] -= xc
        labels9[:, [2, 4]] -= yc
        c = np.array([xc, yc])  # centers
        segments9 = [x - c for x in segments9]

        for x in (labels9[:, 1:], *segments9):
            np.clip(x, 0, 2 * s, out=x)  # clip when using random_perspective()
        # img9, labels9 = replicate(img9, labels9)  # replicate

        # Augment
        img9, labels9, segments9 = copy_paste(img9, labels9, segments9, p=self.hyp['copy_paste'])
        img9, labels9 = random_perspective(img9,
                                           labels9,
                                           segments9,
                                           degrees=self.hyp['degrees'],
                                           translate=self.hyp['translate'],
                                           scale=self.hyp['scale'],
                                           shear=self.hyp['shear'],
                                           perspective=self.hyp['perspective'],
                                           border=self.mosaic_border)  # border to remove

        return img9, labels9

该函数 load_mosaic9 用于加载一个包含1张主要图像和8张随机图像的9图拼接(mosaic)图像

  1. 函数定义与初始化:

    def load_mosaic9(self, index):
        labels9, segments9 = [], []
        s = self.img_size
        indices = [index] + random.choices(self.indices, k=8)  # 8 additional image indices
        random.shuffle(indices)
    
    • labels9 和 segments9 是用来存储拼接图像对应的标签和分段信息。
    • s 是图像的目标尺寸。
    • indices 是一个包含所需加载图像的索引列表,包含当前图像的索引和随机选择的8个其他图像的索引。
  2. 初始化高度和宽度:

    hp, wp = -1, -1  # height, width previous
    
    • hp 和 wp 用于跟踪上一个图像的高度和宽度。
  3. 加载图像及拼接:

    for i, index in enumerate(indices):
        img, _, (h, w) = self.load_image(index)
    
    • 循环遍历各个索引以加载图像,获取高 h 和宽 w
  4. 在拼接图像中放置各个图像:

    if i == 0:  # center
        img9 = np.full((s * 3, s * 3, img.shape[2]), 114, dtype=np.uint8)  # base image with 4 tiles
        h0, w0 = h, w
        c = s, s, s + w, s + h  # xmin, ymin, xmax, ymax (base) coordinates
    
    • 第0张图像放置在拼接图像的中心。
    • 其他图像(第1到第8张)根据不同的位置放置。在每种情况下,定义图像应该被放置的坐标 c
  5. 计算图像位置:

    padx, pady = c[:2]
    x1, y1, x2, y2 = (max(x, 0) for x in c)  # allocate coords
    
    • 计算如何将加载的图像放置到拼接图像的正确位置。
  6. 处理标签与分段信息:

    labels, segments = self.labels[index].copy(), self.segments[index].copy()
    if labels.size:
        labels[:, 1:] = xywhn2xyxy(labels[:, 1:], w, h, padx, pady)  # normalized xywh to pixel xyxy format
        segments = [xyn2xy(x, w, h, padx, pady) for x in segments]
    labels9.append(labels)
    segments9.extend(segments)
    
    • 复制当前图像的标签和分段信息,并将其规模转换为拼接图像的坐标系统。将这些信息附加到 labels9 和 segments9 列表中。
  7. 更新拼接图像:

    img9[y1:y2, x1:x2] = img[y1 - pady:, x1 - padx:]  # img9[ymin:ymax, xmin:xmax]
    hp, wp = h, w  # height, width previous
    
    • 将当前图像的部分内容放入拼接图像的适当位置,并更新记录上一个图像的尺寸。
  8. 偏移拼接图像:

    yc, xc = (int(random.uniform(0, s)) for _ in self.mosaic_border)  # mosaic center x, y
    img9 = img9[yc:yc + 2 * s, xc:xc + 2 * s]
    
    • 随机生成拼接图像的中心偏移量。
  9. 合并与裁剪标签:

    labels9 = np.concatenate(labels9, 0)
    labels9[:, [1, 3]] -= xc
    labels9[:, [2, 4]] -= yc
    c = np.array([xc, yc])  # centers
    segments9 = [x - c for x in segments9]
    
    • 合并所有图像的标签与分段信息,并更新其坐标使之与拼接后的图像相符。
  10. 限制坐标范围:

    for x in (labels9[:, 1:], *segments9):
        np.clip(x, 0, 2 * s, out=x)  # clip when using random_perspective()
    
    • 将标签和分段坐标限制在合法的范围内,以处理映射过程中的潜在超出图像边界的问题。
  11. 数据增强处理:

    img9, labels9, segments9 = copy_paste(img9, labels9, segments9, p=self.hyp['copy_paste'])
    img9, labels9 = random_perspective(img9,
                                       labels9,
                                       segments9,
                                       degrees=self.hyp['degrees'],
                                       translate=self.hyp['translate'],
                                       scale=self.hyp['scale'],
                                       shear=self.hyp['shear'],
                                       perspective=self.hyp['perspective'],
                                       border=self.mosaic_border)  # border to remove
    
    • 对拼接图像及其标签与分段信息应用数据增强,例如随机透视、程度调整等操作。
  12. 返回结果:

    return img9, labels9
    
    • 返回拼接后的图像和相应的标签信息。

此代码的主要功能是生成一个包含1张主图像和8张随机图像的9图拼接(mosaic)图像,适用于YOLOv5模型的数据增强过程。通过这种方式,模型可以从多个视角学习到更多的信息,从而提高其泛化能力。拼接后的图像连同标签信息也随之被处理,以确保目标检测任务中对物体位置的准确性。该方法增强了训练数据的多样性,提高了模型的鲁棒性。

2.6、总结

YOLOV5中的Mosaic增强是一种有效的数据增强技术,它通过拼接四张图像来丰富数据集、提升训练速度和降低内存需求,同时增强模型对小目标的检测能力。在目标检测任务中,Mosaic增强被广泛应用于提高模型的性能和泛化能力。

三、函数random_perspective

这个函数是对mosaic整合后的图片进行仿射变换(旋转、缩放、平移、裁剪,透视变换),并resize为输入大小img_size。

3.1 仿射变换 

仿射变换是指对一个向量进行线性变换,得到另一个向量,变换前后两个向量仍在同一平面上。假设有两个二维向量V和V':


通过线性变换将向量V变为向量V':


将上式展开:


转换为矩阵的乘法:


所以通过矩阵M就可以实现两个向量之间的仿射变换,常见的仿射变换包括:平移、缩放、旋转和翻转。

3.1.1 平移变换

对于二维向量V=(x,y)和V'=(x',y'),通过平移变换将V变为V'的操作如下:


转换为矩阵的形式:

import cv2
import numpy as np
import matplotlib.pyplot as plt

# 显示图片
def show(img1,img2):
    plt.figure()
    plt.subplot(1,2,1)
    plt.imshow(img1)
    plt.title("before(h*w={}*{})".format(img1.shape[0],img1.shape[1]),c="r")
    plt.subplot(1,2,2)
    plt.imshow(img2)
    plt.title("after(h*w={}*{})".format(img2.shape[0],img2.shape[1]),c="r")
    plt.show()
    plt.pause(5)

# 仿射变换之平移
def Affine_translation(img=None,tx=0,ty=0):
    '''
    img:输入图片;
    tx: X方向的偏移量,正数表示向右偏移,负数表示向左偏移;
    ty: Y方向的偏移量,正数表示向下偏移,负数表示向上偏移;
    '''
    # 仿射变换矩阵
    M=np.array([[1,0,tx],[0,1,ty]],dtype=np.float)
    # 输出图片的大小,w*h
    dsize=img.shape[:2][::-1]
    # 仿射变换,黑像素填充
    out=cv2.warpAffine(img,M,dsize,borderValue=(0,0,0))
    return out

if __name__=="__main__":
    img=cv2.imread("dog.jpg")
    img=cv2.cvtColor(img,cv2.COLOR_BGR2RGB)
    out=Affine_translation(img,tx=40,ty=40)
    show(img,out) 

 

3.1.2 缩放变换

对于二维向量V=(x,y)和V'=(x',y'),通过缩放变换将V变为V'的操作如下:


转换为矩阵的形式:

import cv2
import numpy as np
import matplotlib.pyplot as plt

# 显示图片
def show(img1,img2):
    plt.figure()
    plt.subplot(1,2,1)
    plt.imshow(img1)
    plt.title("before(h*w={}*{})".format(img1.shape[0],img1.shape[1]),c="r")
    plt.subplot(1,2,2)
    plt.imshow(img2)
    plt.title("after(h*w={}*{})".format(img2.shape[0],img2.shape[1]),c="r")
    plt.show()
    plt.pause(5)

# 仿射变换之缩放
def Affine_scale(img=None,fx=1,fy=1):
    '''
    img:输入图片;
    fx: X方向的缩放因子;
    fy: Y方向的缩放因子;
    '''
    # 仿射变换矩阵
    M=np.array([[fx,0,0],[0,fy,0]],dtype=np.float)
    # 输出图片的大小,w*h
    dsize=img.shape[:2][::-1]
    # 仿射变换,黑像素填充
    out=cv2.warpAffine(img,M,dsize,borderValue=(0,0,0))
    return out

if __name__=="__main__":
    img=cv2.imread("dog.jpg")
    img=cv2.cvtColor(img,cv2.COLOR_BGR2RGB)
    out=Affine_scale(img,fx=0.5,fy=0.5)
    show(img,out) 

 

3.1.3 旋转变换

对于二维向量V=(x,y)和V'=(x',y'),围绕原点将向量V变为向量V'的操作如下:


转换为矩阵的形式:


因为图像的坐标系是以左上角为原点,所以需要对角度theta进行取反,根据三角函数的奇偶性,矩阵M变为:


如果围绕任意点C(a,b)旋转,将变量V变为V'的操作如下:


转换为矩阵的形式:


同理,因为图像的坐标系是以左上角为原点,所以需要对角度theta进行取反,根据三角函数的奇偶性,矩阵M变为:

import cv2
import numpy as np
import matplotlib.pyplot as plt

# 显示图片
def show(img1,img2):
    plt.figure()
    plt.subplot(1,2,1)
    plt.imshow(img1)
    plt.title("before(h*w={}*{})".format(img1.shape[0],img1.shape[1]),c="r")
    plt.subplot(1,2,2)
    plt.imshow(img2)
    plt.title("after(h*w={}*{})".format(img2.shape[0],img2.shape[1]),c="r")
    plt.show()
    plt.pause(5)

# 仿射变换之旋转
def Affine_rotation(img=None,C=(0,0),theta=0):
    '''
    img:    输入图片;
    C(x,y): 旋转中心;
    theta:  旋转角度;
    '''
    # 角度转为弧度
    theta=theta/180*np.pi
    # 仿射变换矩阵
    M=np.array([[np.cos(theta),np.sin(theta),(1-np.cos(theta))*C[0]-C[1]*np.sin(theta)],
                [-np.sin(theta),np.cos(theta),(1-np.cos(theta))*C[1]+C[0]*np.sin(theta)]],
                dtype=np.float)
    # 输出图片的大小,w*h
    dsize=img.shape[:2][::-1]
    # 仿射变换,黑像素填充
    out=cv2.warpAffine(img,M,dsize,borderValue=(0,0,0))
    return out

if __name__=="__main__":
    img=cv2.imread("dog.jpg")
    img=cv2.cvtColor(img,cv2.COLOR_BGR2RGB)
    h,w=img.shape[:2]
    # 以图片中心点为旋转中心
    C=(int(w/2),int(h/2))
    # 旋转45度
    out=Affine_rotation(img,C,theta=45)
    show(img,out) 

 

为了防止旋转后图片的部分信息被裁剪掉,我们对输出图片的尺寸进行扩充,优化的代码实现如下: 

import cv2
import numpy as np
import matplotlib.pyplot as plt

# 显示图片
def show(img1,img2):
    plt.figure()
    plt.subplot(1,2,1)
    plt.imshow(img1)
    plt.title("before(h*w={}*{})".format(img1.shape[0],img1.shape[1]),c="r")
    plt.subplot(1,2,2)
    plt.imshow(img2)
    plt.title("after(h*w={}*{})".format(img2.shape[0],img2.shape[1]),c="r")
    plt.show()
    plt.pause(5)

# 仿射变换之旋转
def Affine_rotation(img=None,C=(0,0),theta=0,complete=False):
    '''
    img:        输入图片;
    C(x,y):     旋转中心;
    theta:      旋转角度;
    complete:   是否保持图片完整性;
    '''
    # 角度转为弧度
    theta=theta/180*np.pi
    # 仿射变换矩阵
    M=np.array([[np.cos(theta),np.sin(theta),(1-np.cos(theta))*C[0]-C[1]*np.sin(theta)],
                [-np.sin(theta),np.cos(theta),(1-np.cos(theta))*C[1]+C[0]*np.sin(theta)]],
                dtype=np.float)
    # 输出图片的大小,w*h
    dsize=img.shape[:2][::-1]
    if complete:
        w,h=dsize[0],dsize[1]
        # 增大输出图像的宽和高,防止被裁剪掉
        new_w=w*np.cos(theta)+h*np.sin(theta)
        new_h=w*np.sin(theta)+h*np.cos(theta)
        # 增大变换矩阵的平移参数
        M[0,2]+=(new_w-w)*0.5
        M[1,2]+=(new_h-h)*0.5
        w=int(np.round(new_w))
        h=int(np.round(new_h))
        dsize=[w,h]
    # 仿射变换,黑像素填充
    out=cv2.warpAffine(img,M,dsize,borderValue=(0,0,0))
    return out

if __name__=="__main__":
    img=cv2.imread("dog.jpg")
    img=cv2.cvtColor(img,cv2.COLOR_BGR2RGB)
    h,w=img.shape[:2]
    # 以图片中心点为旋转中心
    C=(int(w/2),int(h/2))
    # 旋转45度
    out=Affine_rotation(img,C,theta=45,complete=True)
    show(img,out) 

3.1.4 翻转变换

翻转包括水平翻转、垂直翻转和镜像翻转,对于二维向量V=(x,y)和V'=(x',y'),通过翻转变换将V变为V'的操作如下:


转换为矩阵的形式:

import cv2
import numpy as np
import matplotlib.pyplot as plt

# 显示图片
def show(img1,img2):
    plt.figure()
    plt.subplot(1,2,1)
    plt.imshow(img1)
    plt.title("before(h*w={}*{})".format(img1.shape[0],img1.shape[1]),c="r")
    plt.subplot(1,2,2)
    plt.imshow(img2)
    plt.title("after(h*w={}*{})".format(img2.shape[0],img2.shape[1]),c="r")
    plt.show()
    plt.pause(5)

# 仿射变换之翻转
def Affine_flip(img=None,s=""):
    '''
    img:    输入图片;
    s:      翻转类型,包括Horizontal,Vertical,Mirror
    '''
    # 输出图片的大小,w*h
    w,h=img.shape[:2][::-1]
    # 仿射变换矩阵
    if s=="Horizontal":
        M=np.array([[-1,0,w],[0,1,0]],dtype=np.float)
    if s=="Vertical":
        M=np.array([[1,0,0],[0,-1,h]],dtype=np.float)
    if s=="Mirror":
        M=np.array([[-1,0,w],[0,-1,h]],dtype=np.float)
    # 仿射变换,黑像素填充
    out=cv2.warpAffine(img,M,(w,h),borderValue=(0,0,0))
    return out

if __name__=="__main__":
    img=cv2.imread("dog.jpg")
    img=cv2.cvtColor(img,cv2.COLOR_BGR2RGB)
    # 翻转
    out=Affine_flip(img,s="Horizontal")
    show(img,out) 

3.1.5 错切变换

向量V=[x,y]通过错切变换到V'=[x',y'],变换矩阵M如下图:


错切变换主要是M12和M21两个参数起作用,代码实现如下: 

# 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) 作者:YouOnly_LiveOnce https://www.bilibili.com/read/cv23506379/?from=readlist 出处:bilibili
import cv2
import random
import math
import numpy as np
import matplotlib.pyplot as plt

# 显示图像
def show(img):
    img=cv2.cvtColor(img,cv2.COLOR_BGR2RGB)
    plt.figure()
    plt.subplot(1,1,1)
    plt.imshow(img)
    plt.show()
    plt.pause(5)

# 仿射变换+透视变换
def random_perspective(img,labels=(),perspective=0.0008,degrees=-30,scale=0.5,shear=12,border=[-320,-320]):
    # 1280*1280 -> 640*640
    height = img.shape[0] + border[0] * 2
    width = img.shape[1] + border[1] * 2

    # 一次平移
    C = np.eye(3)
    C[0, 2] = -img.shape[1] / 2  # x translation (pixels)
    C[1, 2] = -img.shape[0] / 2  # y translation (pixels)

    # 透视变换
    P = np.eye(3)
    P[2, 0] = random.uniform(-perspective, perspective)  # x perspective (about y)
    P[2, 1] = random.uniform(-perspective, perspective)  # y perspective (about x)

    # 旋转和缩放
    R = np.eye(3)
    a = random.uniform(-degrees, degrees)       # 旋转角度
    s = random.uniform(1 - scale, 1 + scale)    # 缩放尺度
    # 获取旋转和缩放矩阵
    R[:2] = cv2.getRotationMatrix2D(angle=a, center=(0, 0), scale=s)

    # 错切
    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)

    M=S # 变换矩阵M
    img = cv2.warpPerspective(img, M, dsize=(1280,1280), borderValue=(114, 114, 114))
    return img

if __name__=="__main__":
    # Mosaic数据增强后的图片和标签
    img4=cv2.imread("img4.jpg")
    print(img4.shape) # 1280*1280*3
    labels4=np.array([
        [0.0000000e+00,2.2236000e+02,3.2076001e+02,4.8859998e+02,5.8188000e+02],
        [2.0000000e+00,9.0332001e+02,2.7596002e+02,1.0121200e+03,5.3964001e+02],
        [2.0000000e+00,9.9547998e+02,2.5420001e+02,1.1362800e+03,5.4988000e+02],
        [3.0000000e+00,8.6491998e+02,4.0651999e+02,1.2310000e+03,6.2156006e+02],
        [4.0000000e+00,1.0646000e+03,3.2588000e+02,1.2800000e+03,4.3084000e+02],
        [0.0000000e+00,8.6278221e+01,6.6891998e+02,6.8704437e+02,1.2800000e+03],
        [1.0000000e+00,8.6364001e+02,8.0332001e+02,1.2118000e+03,9.8123999e+02]],dtype=np.float32)
    # 数据增强
    img4=random_perspective(img4)
    show(img4) 

3.2 透视变换

3.2.1 透视变换原理
 

透视变换(Perspective Transformation) 就是将一个平面通过一个投影矩阵投影到指定平面上。

 透视变换是把图像投影到新的视平面,如上图所示,新平面如果与原图像平面平行那就是简单的仿射变换,不平行那就是绕x/y轴发生了旋转,即空间点的旋转变换

上面的透视变换矩阵M,可以将其拆成四个部分:

 透视变换是一个从二维空间变换到三维空间的转换,我们最终要得到的是图像在二维平面上的投影,故除以Z, (X’,Y’)表示二维平面上图像上的点:

 通过上面的8个方程,我们可以解出8个参数求出透视变换矩阵,最后我们通过opencv的warpPerspective方法利用透视变换矩阵来实现透视变换,接下来我们通过结合一个实例来具体运用一下。

3.2 OpenCV实现

  • 读取图像
#读取图像
img = cv2.imread("poker.jpg")
cv2.imshow("img",cv2.resize(img,(int(0.5*img.shape[1]),int(0.5*img.shape[0]))))
cv2.waitKey(0)
#将原图转为灰度图
gray_img = cv2.cvtColor(img,cv2.COLOR_BGR2GRAY)
  • Canny边缘检测
Canny函数参数解析:

image:输入图像数组
threshold1:最低的阈值
threshold2:最高的阈值
edges:输出的边缘图像,单通道8位图像
apertureSize:Sobel算子的大小
L2gradient:布尔值,如果为真,则使用更精确的L2范数进行计算,否则使用L1范数
​
 
#Canny边缘检测
canny_img = cv2.Canny(gray_img,180,200,3)
#显示边缘检测后的图像
cv2.imshow("canny_img",cv2.resize(canny_img,(int(0.5*canny_img.shape[1]),int(0.5*canny_img.shape[0]))))
cv2.waitKey(0)

 

  • 霍夫直线检测
HoughLinesP函数参数解析:
  
image:经过Canny边缘检测后的输出图像
rho:极坐标的半径r以像素值为单位的分辨率,一般使用1像素
theta:极坐标的极角θ以弧度为单位的分辨率,一般使用1度
threshold:检测一条直线所需最少的曲线交点
lines:存储检测到的直线,包含直线的起点和终点坐标
minLineLength:组成一条直线的最少点的数量,点数量不足的直线将被抛弃
maxLineGap:在一条直线上的点的最大距离
​
​
def draw_line(img,lines):
    # 绘制直线
    for line_points in lines:
        cv2.line(img,(line_points[0][0],line_points[0][1]),(line_points[0][2],line_points[0][3]),
                (0,255,0),2,8,0)
    cv2.imshow("line_img", cv2.resize(img,(int(0.5*img.shape[1]),int(0.5*img.shape[0]))))
    cv2.waitKey(0)
# #Hough直线检测
lines = cv2.HoughLinesP(canny_img,1,np.pi/180,70,minLineLength=150,maxLineGap=30)[0:4]
#基于边缘检测的图像来检测直线
draw_line(img,lines)


 

  • 计算顶点坐标
    通过直线两个端点的坐标来计算直线的交点坐标,找出扑克牌的四个顶点位置

#计算四条直线的交点作为顶点坐标
def computer_intersect_point(lines):
    def get_line_k_b(line_point):
        """计算直线的斜率和截距
        :param line_point: 直线的坐标点
        :return:
        """
        #获取直线的两点坐标
        x1,y1,x2,y2 = line_point[0]
        #计算直线的斜率和截距
        k = (y1 - y2)/(x1 - x2)
        b = y2 - x2 * (y1 - y2)/(x1 - x2)
        return k,b
    #用来存放直线的交点坐标
    line_intersect = []
    for i in range(len(lines)):
        k1,b1 = get_line_k_b(lines[i])
        for j in range(i+1,len(lines)):
            k2,b2 = get_line_k_b(lines[j])
            #计算交点坐标
            x = (b2 - b1) / (k1 - k2)
            y = k1 * (b2 - b1)/(k1 -k2) + b1
            if x > 0 and y > 0:
                line_intersect.append((int(np.round(x)),int(np.round(y))))
    return line_intersect
def draw_point(img,points):
    for position in points:
        cv2.circle(img,position,5,(0,0,255),-1)
    cv2.imshow("draw_point",cv2.resize(img,(int(0.5*img.shape[1]),int(0.5*img.shape[0]))))
    cv2.waitKey(0)
#计算直线的交点坐标
line_intersect = computer_intersect_point(lines)
#绘制交点坐标的位置
draw_point(img,line_intersect)

  • 对顶点坐标进行排序
    在计算透视变换矩阵之前我们需要对元素图像的坐标与变换后图像的坐标一一对应,按照左->上->右->下的顺序
def order_point(points):
    """对交点坐标进行排序
    :param points:
    :return:
    """
    points_array = np.array(points)
    #对x的大小进行排序
    x_sort = np.argsort(points_array[:,0])
    #对y的大小进行排序
    y_sort = np.argsort(points_array[:,1])
    #获取最左边的顶点坐标
    left_point = points_array[x_sort[0]]
    #获取最右边的顶点坐标
    right_point = points_array[x_sort[-1]]
    #获取最上边的顶点坐标
    top_point = points_array[y_sort[0]]
    #获取最下边的顶点坐标
    bottom_point = points_array[y_sort[-1]]
    return np.array([left_point,top_point,right_point,bottom_point],dtype=np.float32)
def target_vertax_point(clockwise_point):
    #计算顶点的宽度(取最大宽度)
    w1 = np.linalg.norm(clockwise_point[0]-clockwise_point[1])
    w2 = np.linalg.norm(clockwise_point[2]-clockwise_point[3])
    w = w1 if w1 > w2 else w2
    #计算顶点的高度(取最大高度)
    h1 = np.linalg.norm(clockwise_point[1]-clockwise_point[2])
    h2 = np.linalg.norm(clockwise_point[3]-clockwise_point[0])
    h = h1 if h1 > h2 else h2
    #将宽和高转换为整数
    w = int(round(w))
    h = int(round(h))
    #计算变换后目标的顶点坐标
    top_left = [0,0]
    top_right = [w,0]
    bottom_right = [w,h]
    bottom_left = [0,h]
    return np.array([top_left,top_right,bottom_right,bottom_left],dtype=np.float32)
#对原始图像的交点坐标进行排序
clockwise_point = order_point(line_intersect)
#获取变换后坐标的位置
target_clockwise_point = target_vertax_point(clockwise_point)
  • 计算变换矩阵进行透视变换
#计算变换矩阵
matrix = cv2.getPerspectiveTransform(clockwise_point,target_clockwise_point)
print(matrix)
#计算透视变换后的图片
perspective_img = cv2.warpPerspective(img,matrix,(target_clockwise_point[2][0],target_clockwise_point[2][1]))
cv2.imshow("perspective_img",cv2.resize(perspective_img,(int(0.5*perspective_img.shape[1]),int(0.5*perspective_img.shape[0]))))
cv2.waitKey(0)

3.3 仿射变换与透视变换区别 

 透视变换与仿射变换在图像处理中扮演着不同的角色,它们之间存在明显的区别。以下是对两者区别的详细阐述:

1. 定义与原理

  • 仿射变换(Affine Transformation)

    • 仿射变换是一种二维坐标到二维坐标之间的线性变换,并保持二维图形的平直性和平行性。

    • 它可以通过线性矩阵运算来实现,包括旋转、平移、缩放和倾斜等操作。

    • 仿射变换的特点是平行关系和线段的长度比例保持不变。

  • 透视变换(Perspective Transformation)

    • 透视变换是将图片投影到一个新的视平面(或称为投影映射),它不仅仅是线性变换,还涉及到投影的计算。

    • 透视变换可以改变图像的视角和距离感,常用于处理摄像机捕捉的图像场景。

    • 透视变换的特点是除了仿射变换的变换外,还可以改变线段长度比例,即平行线在变换后可能不再平行。

2. 变换特性

特性

仿射变换

透视变换

平行性

保持平行

可能不保持平行

线段长度比例

保持不变

可能改变

变换类型

线性变换

非线性变换(包含线性变换和投影变换)

变换矩阵

6个未知数(二维空间变换)

8个未知数(三维空间变换到二维平面的投影)

3. 应用场景

  • 仿射变换

    • 适用于图像旋转、平移、缩放等简单变换,保持图像的基本形状和比例关系不变。

    • 在图像配准、图像拼接等领域有广泛应用。

  • 透视变换

    • 适用于处理摄像机拍摄的图像,特别是当摄像机与拍摄对象之间存在角度或距离变化时。

    • 常用于图像的校正、三维重建、虚拟现实等领域。

4. 变换效果

  • 仿射变换后的图像,虽然形状和大小可能发生变化,但图像中的平行线仍然保持平行,且线段长度比例不变。

  • 透视变换后的图像,由于引入了投影计算,平行线可能不再平行,且线段长度比例也可能发生变化,从而呈现出更真实的空间感和视角变化。

综上所述,透视变换与仿射变换在定义、原理、变换特性、应用场景和变换效果等方面都存在明显的区别。在实际应用中,应根据具体需求选择合适的变换方法。

 3.4 目标框坐标调整原理实现

标签数据的格式如下图:


图像的坐标通过矩阵M进行仿射变换和透视变换,我们将标签坐标也乘以相同的矩阵M,就可以完成对标签坐标的调整,如下图:


以含有两个bbox的标签信息举例,YOLOv5对标签调整的过程如下:

第一步:生成一个4n*3的全一数组,n表示bbox个数,如下图:


代码实现:

xy = np.ones((n * 4, 3))

第二步:将每个bbox的四个坐标放入新建的数组,即左上角(x1,y1)、右下角(x2,y2)、左下角(x1,y2)和右上角(x2,y1),如下图:


代码实现:

xy[:, :2] = targets[:, [1, 2, 3, 4, 1, 4, 3, 2]].reshape(n * 4, 2)  # x1y1, x2y2, x1y2, x2y1

第三步:标签坐标与矩阵M相乘,完成对标签坐标的调整,如下图:


然后调整数组的形状,将每个bbox的四个坐标放到同一行,如下图:


代码实现如下:

xy = xy @ M.T  # transform
if perspective:
  xy = (xy[:, :2] / xy[:, 2:3]).reshape(n, 8)  # rescale
else:    # affine
  xy = xy[:, :2].reshape(n, 8)

第四步:bbox通过矩阵M变换后已不再是一个矩形,需要根据四个坐标将bbox重新变成矩形,如下图:


实现过程如下图:


代码实现如下:

# create new boxes
x = xy[:, [0, 2, 4, 6]]
y = xy[:, [1, 3, 5, 7]]
xy = np.concatenate((x.min(1), y.min(1), x.max(1), y.max(1))).reshape(4, n).T
第五步:裁剪bbox的坐标,将X取值限制为0~width,Y取值限制为0~height,代码实现如下:

# clip boxes
xy[:, [0, 2]] = xy[:, [0, 2]].clip(0, width)
xy[:, [1, 3]] = xy[:, [1, 3]].clip(0, height)


第六步:对变换后的bbox进行进行筛选,如下图:


代码实现如下:

def box_candidates(box1, box2, wh_thr=2, ar_thr=20, area_thr=0.1):  # box1(4,n), box2(4,n)
    # Compute candidate boxes: box1 before augment, box2 after augment, wh_thr (pixels), aspect_ratio_thr, area_ratio
    w1, h1 = box1[2] - box1[0], box1[3] - box1[1]
    w2, h2 = box2[2] - box2[0], box2[3] - box2[1]
    ar = np.maximum(w2 / (h2 + 1e-16), h2 / (w2 + 1e-16))  # aspect ratio
    return (w2 > wh_thr) & (h2 > wh_thr) & (w2 * h2 / (w1 * h1 + 1e-16) > area_thr) & (ar < ar_thr)  # candidates
第七步:完成bbox标签信息的调整,更新标签信息,代码如下:

# filter candidates
i = box_candidates(box1=targets[:, 1:5].T * s, box2=xy.T)
targets = targets[i]
targets[:, 1:5] = xy[i]

第七步:完成bbox标签信息的调整,更新标签信息,代码如下:

# filter candidates
i = box_candidates(box1=targets[:, 1:5].T * s, box2=xy.T)
targets = targets[i]
targets[:, 1:5] = xy[i] 
import cv2
import random
import math
import numpy as np
import matplotlib.pyplot as plt

# 显示图像
def show(img):
    img=cv2.cvtColor(img,cv2.COLOR_BGR2RGB)
    plt.figure()
    plt.subplot(1,1,1)
    plt.imshow(img)
    plt.show()
    plt.pause(5)

def box_candidates(box1, box2, wh_thr=2, ar_thr=20, area_thr=0.1):  # box1(4,n), box2(4,n)
    # Compute candidate boxes: box1 before augment, box2 after augment, wh_thr (pixels), aspect_ratio_thr, area_ratio
    w1, h1 = box1[2] - box1[0], box1[3] - box1[1]
    w2, h2 = box2[2] - box2[0], box2[3] - box2[1]
    ar = np.maximum(w2 / (h2 + 1e-16), h2 / (w2 + 1e-16))  # aspect ratio
    return (w2 > wh_thr) & (h2 > wh_thr) & (w2 * h2 / (w1 * h1 + 1e-16) > area_thr) & (ar < ar_thr)  # candidates

# 仿射变换+透视变换
def random_perspective(img,targets=(),perspective=0.0008,degrees=-30,scale=0.5,shear=15,translate=0.1,border=[-320,-320]):
    # 1280*1280 -> 640*640
    height = img.shape[0] + border[0] * 2
    width = img.shape[1] + border[1] * 2

    # 一次平移
    C = np.eye(3)
    C[0, 2] = -img.shape[1] / 2  # x translation (pixels)
    C[1, 2] = -img.shape[0] / 2  # y translation (pixels)

    # 透视变换
    P = np.eye(3)
    P[2, 0] = random.uniform(-perspective, perspective)  # x perspective (about y)
    P[2, 1] = random.uniform(-perspective, perspective)  # y perspective (about x)

    # 旋转和缩放
    R = np.eye(3)
    a = random.uniform(-degrees, degrees)       # 旋转角度
    s = random.uniform(1 - scale, 1 + scale)    # 缩放尺度
    # 获取旋转和缩放矩阵
    R[:2] = cv2.getRotationMatrix2D(angle=a, center=(0, 0), scale=s)

    # 错切
    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)

    # 平移
    T = np.eye(3)
    T[0, 2] = random.uniform(0.5 - translate, 0.5 + translate) * width  # x translation (pixels)
    T[1, 2] = random.uniform(0.5 - translate, 0.5 + translate) * height  # y translation (pixels)

    M = T @ S @ R @ P @ C # 变换矩阵M:C-P-R-S-T
    img = cv2.warpPerspective(img, M, dsize=(width,height), borderValue=(114, 114, 114))

    n = len(targets)
    if n:
        # warp points
        xy = np.ones((n * 4, 3))
        xy[:, :2] = targets[:, [1, 2, 3, 4, 1, 4, 3, 2]].reshape(n * 4, 2)  # x1y1, x2y2, x1y2, x2y1
        xy = xy @ M.T  # transform
        if perspective:
            xy = (xy[:, :2] / xy[:, 2:3]).reshape(n, 8)  # rescale
        else:  # affine
            xy = xy[:, :2].reshape(n, 8)

        # create new boxes
        x = xy[:, [0, 2, 4, 6]]
        y = xy[:, [1, 3, 5, 7]]
        xy = np.concatenate((x.min(1), y.min(1), x.max(1), y.max(1))).reshape(4, n).T

        # clip boxes
        xy[:, [0, 2]] = xy[:, [0, 2]].clip(0, width)
        xy[:, [1, 3]] = xy[:, [1, 3]].clip(0, height)

        # filter candidates
        i = box_candidates(box1=targets[:, 1:5].T*s, box2=xy.T)
        targets = targets[i]
        targets[:, 1:5] = xy[i]
    return img,targets

if __name__=="__main__":
    # Mosaic数据增强后的图片和标签
    img4=cv2.imread("img4.jpg")
    print(img4.shape) # 1280*1280*3
    labels4=np.array([
        [0.0000000e+00,2.2236000e+02,3.2076001e+02,4.8859998e+02,5.8188000e+02],
        [2.0000000e+00,9.0332001e+02,2.7596002e+02,1.0121200e+03,5.3964001e+02],
        [2.0000000e+00,9.9547998e+02,2.5420001e+02,1.1362800e+03,5.4988000e+02],
        [3.0000000e+00,8.6491998e+02,4.0651999e+02,1.2310000e+03,6.2156006e+02],
        [4.0000000e+00,1.0646000e+03,3.2588000e+02,1.2800000e+03,4.3084000e+02],
        [0.0000000e+00,8.6278221e+01,6.6891998e+02,6.8704437e+02,1.2800000e+03],
        [1.0000000e+00,8.6364001e+02,8.0332001e+02,1.2118000e+03,9.8123999e+02]],dtype=np.float32)
    # 数据增强
    img4,labels4=random_perspective(img4,targets=labels4)
    for i in range(len(labels4)):
        box=labels4[i]
        # 左上角,右下角
        c1,c2=(int(box[1]),int(box[2])),(int(box[3]),int(box[4]))
        cv2.rectangle(img4,c1,c2,(0,255,0),thickness=4,lineType=cv2.LINE_AA)
    show(img4) 

3.5  YOLOV5代码解析 

def random_perspective(im,
                       targets=(),
                       segments=(),
                       degrees=10,
                       translate=.1,
                       scale=.1,
                       shear=10,
                       perspective=0.0,
                       border=(0, 0)):
    # torchvision.transforms.RandomAffine(degrees=(-10, 10), translate=(0.1, 0.1), scale=(0.9, 1.1), shear=(-10, 10))
    # targets = [cls, xyxy]

    height = im.shape[0] + border[0] * 2  # shape(h,w,c)
    width = im.shape[1] + border[1] * 2

    # Center
    C = np.eye(3)
    C[0, 2] = -im.shape[1] / 2  # x translation (pixels)
    C[1, 2] = -im.shape[0] / 2  # y translation (pixels)

    # Perspective
    P = np.eye(3)
    P[2, 0] = random.uniform(-perspective, perspective)  # x perspective (about y)
    P[2, 1] = random.uniform(-perspective, perspective)  # y perspective (about x)

    # Rotation and Scale
    R = np.eye(3)
    a = random.uniform(-degrees, degrees)
    # a += random.choice([-180, -90, 0, 90])  # add 90deg rotations to small rotations
    s = random.uniform(1 - scale, 1 + scale)
    # s = 2 ** random.uniform(-scale, scale)
    R[:2] = cv2.getRotationMatrix2D(angle=a, center=(0, 0), scale=s)

    # 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)

    # Translation
    T = np.eye(3)
    T[0, 2] = random.uniform(0.5 - translate, 0.5 + translate) * width  # x translation (pixels)
    T[1, 2] = random.uniform(0.5 - translate, 0.5 + translate) * height  # y translation (pixels)

    # Combined rotation matrix
    M = T @ S @ R @ P @ C  # order of operations (right to left) is IMPORTANT
    if (border[0] != 0) or (border[1] != 0) or (M != np.eye(3)).any():  # image changed
        if perspective:
            im = cv2.warpPerspective(im, M, dsize=(width, height), borderValue=(114, 114, 114))
        else:  # affine
            im = cv2.warpAffine(im, M[:2], dsize=(width, height), borderValue=(114, 114, 114))

    # Visualize
    # import matplotlib.pyplot as plt
    # ax = plt.subplots(1, 2, figsize=(12, 6))[1].ravel()
    # ax[0].imshow(im[:, :, ::-1])  # base
    # ax[1].imshow(im2[:, :, ::-1])  # warped

    # Transform label coordinates
    n = len(targets)
    if n:
        use_segments = any(x.any() for x in segments)
        new = np.zeros((n, 4))
        if use_segments:  # warp segments
            segments = resample_segments(segments)  # upsample
            for i, segment in enumerate(segments):
                xy = np.ones((len(segment), 3))
                xy[:, :2] = segment
                xy = xy @ M.T  # transform
                xy = xy[:, :2] / xy[:, 2:3] if perspective else xy[:, :2]  # perspective rescale or affine

                # clip
                new[i] = segment2box(xy, width, height)

        else:  # warp boxes
            xy = np.ones((n * 4, 3))
            xy[:, :2] = targets[:, [1, 2, 3, 4, 1, 4, 3, 2]].reshape(n * 4, 2)  # x1y1, x2y2, x1y2, x2y1
            xy = xy @ M.T  # transform
            xy = (xy[:, :2] / xy[:, 2:3] if perspective else xy[:, :2]).reshape(n, 8)  # perspective rescale or affine

            # create new boxes
            x = xy[:, [0, 2, 4, 6]]
            y = xy[:, [1, 3, 5, 7]]
            new = np.concatenate((x.min(1), y.min(1), x.max(1), y.max(1))).reshape(4, n).T

            # clip
            new[:, [0, 2]] = new[:, [0, 2]].clip(0, width)
            new[:, [1, 3]] = new[:, [1, 3]].clip(0, height)

        # filter candidates
        i = box_candidates(box1=targets[:, 1:5].T * s, box2=new.T, area_thr=0.01 if use_segments else 0.10)
        targets = targets[i]
        targets[:, 1:5] = new[i]

    return im, targets

这段代码的作用是对图像进行随机的仿射变换和透视变换,并相应地调整目标框的坐标,以便于在训练深度学习模型时进行数据增强。

3.5.1 初始化图像尺寸和边界

height = im.shape[0] + border[0] * 2  # shape(h,w,c)
width = im.shape[1] + border[1] * 2

获取原始图像的高度和宽度,并根据提供的边界参数增加图像的尺寸。

这段代码的目的是计算图像的高度和宽度,同时考虑边界(border)的影响。以下是对代码的逐步分解和详细解释:

  1. 获取图像形状

    im.shape[0]
    

    im.shape 是一个返回图像尺寸的元组,格式为 (height, width, channels),所以 im.shape[0] 表示图像的高度。

    im.shape[1]
    

    同理,im.shape[1] 表示图像的宽度。

  2. 考虑边界

    border[0] * 2
    

    这里 border[0] 表示图像在垂直方向(高度)的边界厚度。由于边界是在图像的顶部和底部各添加一层,所以需要乘以 2 来计算边界对总高度的影响。

    border[1] * 2
    

    同理,border[1] 表示图像在水平方向(宽度)的边界厚度。也同样需要乘以 2 来计算边界对总宽度的影响。

  3. 计算新的高度和宽度

    height = im.shape[0] + border[0] * 2
    width = im.shape[1] + border[1] * 2
    

    这两行代码将图像的原高度和宽度与相应的边界厚度相加,从而得到了新的高度和宽度。

这段代码的主要功能是计算加上边界厚度之后的图像高度和宽度。它通过获取图像的原始尺寸并将边界厚度考虑在内,从而在处理图像时能够准确地进行裁剪或填充,确保后续操作的正确性和美观性。

3.5.2 平移矩阵 (Center)

C = np.eye(3)
C[0, 2] = -im.shape[1] / 2  # x translation (pixels)
C[1, 2] = -im.shape[0] / 2  # y translation (pixels)

构造一个平移矩阵,将图像平移到中心,以便进行后续的旋转和缩放。

这段代码的目的是创建一个平移矩阵C,以便在图像增强过程中对图像进行中心化处理。下面逐步分解并详细解释每一行:

  1. C = np.eye(3)

    • 这行代码使用NumPy库创建了一个3x3的单位矩阵(identity matrix)。单位矩阵是一个对角线上的元素为1,其余元素为0的方阵。
    • 在图像变换中,3x3的矩阵通常用于表示仿射变换,包括平移、旋转和缩放。
  2. C[0, 2] = -im.shape[1] / 2

    • 这一行设置了矩阵C第一行第三列的值。im.shape[1]表示图像的宽度(columns),因为形状是以(高度, 宽度, 通道数)的形式表示的。
    • -im.shape[1] / 2计算的是图像宽度的一半,并取其相反数。这实际上是将图像的中心点移动到原点的位置(0, 0)。也就是说,我们希望在进行变换前将图像重新定位到坐标系的中心。
  3. C[1, 2] = -im.shape[0] / 2

    • 此行类似于上一行,但它设置了矩阵C第二行第三列的值。im.shape[0]表示图像的高度(rows)。
    • -im.shape[0] / 2同样计算图像高度的一半并取其相反数,目的是确保图像的竖直中心也移动到原点。

这段代码的主要功能是构建一个3x3的平移矩阵C,以便在图像处理过程中将图像的中心移动到坐标系的原点。这种操作通常用于图像变换,特别是在进行旋转和缩放时,中心化处理使得这些变换更加自然和对称。通过平移矩阵C,图像可以在坐标系中以其中心为基点进行进一步的仿射变换。

3.5.3 透视矩阵 (Perspective)

P = np.eye(3)
P[2, 0] = random.uniform(-perspective, perspective)  # x perspective (about y)
P[2, 1] = random.uniform(-perspective, perspective)  # y perspective (about x)

创建一个透视变换矩阵,施加随机的透视变换。

这段代码主要是在生成一个用于图像透视变换的矩阵。我们逐步分解并详细解释这段代码:

  1. 创建单位矩阵

    P = np.eye(3)
    

    np.eye(3) 创建一个 3x3 的单位矩阵 P。单位矩阵对于线性变换的作用是保持不变,即任何向量与单位矩阵相乘都不会改变它的值。在计算机视觉中,3x3 矩阵常用于表示图像的仿射或透视变换。

  2. 设置 x 方向的透视变换

    P[2, 0] = random.uniform(-perspective, perspective)  # x perspective (about y)
    

    P[2, 0] 代表透视矩阵的第三行第一列的元素。这一行的设置影响了 x 方向的透视效果。具体来说,通过从 -perspective 到 perspective 的随机值来修改这个参数,可以控制图像在 x 轴方向上的透视畸变程度。

  3. 设置 y 方向的透视变换

    P[2, 1] = random.uniform(-perspective, perspective)  # y perspective (about x)
    

    P[2, 1] 代表透视矩阵的第三行第二列的元素。这一行的设置影响了 y 方向的透视效果。和 x 方向一样,这个参数的随机值也控制图像在 y 轴方向上的透视畸变程度。

这段代码的主要功能是创建一个 3x3 的透视变换矩阵,随机设置其 x 和 y 方向的透视效果。透视变换可以模拟相机视角的变化,适用于增强图像数据的真实感,尤其在物体检测等计算机视觉任务中,通过引入随机透视变换,可以增加模型的鲁棒性和泛化能力。

3.5.3 旋转和缩放矩阵 (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=(0, 0), scale=s)

生成旋转矩阵和缩放矩阵,旋转角度和缩放比例都是随机产生的。

R = np.eye(3)  # 创建一个3x3的单位矩阵R
  • np.eye(3):使用NumPy库创建一个3x3的单位矩阵。单位矩阵是一种对角线上元素为1,其余元素为0的方阵。在这里,矩阵R将用于后续的变换运算。
a = random.uniform(-degrees, degrees)  # 随机生成一个角度a,范围是-degrees到degrees之间
  • random.uniform(-degrees, degrees):生成一个在-degreesdegrees之间的随机浮点数,这个值用于确定图像旋转的角度。degrees是传入的参数,它限制了旋转的最大范围。
# a += random.choice([-180, -90, 0, 90])  # 可以随机增加90度的旋转
  • 这行代码被注释掉了。如果使用,随机选择加上-180-90090,用于对小角度旋转进行调整。这有助于扩展旋转范围。
s = random.uniform(1 - scale, 1 + scale)  # 根据给定的scale生成一个缩放因子s
  • random.uniform(1 - scale, 1 + scale):随机生成一个缩放因子s,其范围为1 - scale1 + scalescale参数控制缩放的幅度,用于图像放大或缩小。
# s = 2 ** random.uniform(-scale, scale)  # 另一种生成缩放因子的方式(被注释掉)
  • 同样被注释掉的代码,提供了另一种生成缩放因子的方法,结果是以2为底的指数形式生成,可能用于生成更广泛的缩放因子。
R[:2] = cv2.getRotationMatrix2D(angle=a, center=(0, 0), scale=s)  # 计算旋转矩阵
  • cv2.getRotationMatrix2D(angle=a, center=(0, 0), scale=s):使用OpenCV的getRotationMatrix2D函数创建一个旋转矩阵。这个函数的参数包括:
    • angle=a:旋转的角度。
    • center=(0, 0):旋转的中心点。这意味着图像将围绕原点进行旋转(通常情况下,你可能会选择图像的中心)。
    • scale=s:缩放因子,确定图像放大或缩小的比例。
  • R[:2] =:这行代码将生成的旋转矩阵的前两行保存到R的前两行中,完成图像的旋转和缩放准备。

这段代码的主要功能是生成一个用于对图像进行旋转和缩放的变换矩阵。首先,它创建了一个单位矩阵,然后随机生成一个旋转角度和缩放因子。最后,使用OpenCV的函数计算出旋转矩阵,并将其保存到单位矩阵R中。这个变换矩阵R可以用于后续对图像的处理,例如在数据增强时随机旋转和缩放图像。整体来看,这段代码是在进行数据预处理的过程中,特别是在图像数据增强和变换中非常重要。

3.5.4 剪切矩阵 (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)

创建剪切变换矩阵,施加随机的剪切变换。

这段代码的作用是生成一个用于图像变换的剪切矩阵(shear matrix),尤其是在进行数据增强时。以下是对代码每一部分的逐步分解和详细解释:

  1. 初始化单位矩阵:

    S = np.eye(3)
    

    这里使用 np.eye(3) 创建了一个 3x3 的单位矩阵 S,单位矩阵在变换(仿射变换等)时是一个起始的基础。单位矩阵的主要特性是对其进行乘法运算时不会改变其他矩阵。

  2. 计算 X 方向的剪切参数:

    S[0, 1] = math.tan(random.uniform(-shear, shear) * math.pi / 180)  # x shear (deg)
    

    在这一行中:

    • random.uniform(-shear, shear) 生成一个在 -shear 和 shear 之间的随机浮点数,这个值代表剪切角度(以度为单位)。通过这种方式,剪切角度是随机的,从而引入了不确定性,有助于数据增强。
    • math.pi / 180 将度数转换为弧度,因为 math.tan() 函数需要弧度作为输入。
    • math.tan(...) 计算这个剪切角度的切线值,之后将计算得到的切线值存储在剪切矩阵 S 的位置 [0, 1],表示 X 方向的剪切。
  3. 计算 Y 方向的剪切参数:

    S[1, 0] = math.tan(random.uniform(-shear, shear) * math.pi / 180)  # y shear (deg)
    

    这个过程与上一步类似:

    • 随机生成一个在 -shear 和 shear 之间的值,用于计算 Y 方向的剪切角度。
    • 将计算出的切线值存储在剪切矩阵 S 的位置 [1, 0],表示 Y 方向的剪切。

这段代码的主要功能是生成一个 3x3 的剪切矩阵 S,该矩阵用于在随机的 X 和 Y 方向上进行剪切变换。通过随机化剪切角度,它可以增强数据集的多样性,使得训练模型对不同的视角和变换具有更好的鲁棒性。这种数据增强技术在图像处理和计算机视觉领域是常见的,通常用于提高模型的泛化能力。

3.5.5 平移矩阵 (Translation)

T = np.eye(3)
T[0, 2] = random.uniform(0.5 - translate, 0.5 + translate) * width  # x translation (pixels)
T[1, 2] = random.uniform(0.5 - translate, 0.5 + translate) * height  # y translation (pixels)

生成平移矩阵,以随机的方式进行 x 和 y 方向的平移。

让我们逐步分解并详细解释所给的代码片段:

T = np.eye(3)
  • 这一行创建了一个3x3的单位矩阵T。在计算机图形学中,变换矩阵通常使用3x3或者4x4格式表示,特别是在进行图像变换时。单位矩阵是指主对角线上的元素为1,其余元素为0,表示无变换(即不改变图像)。
T[0, 2] = random.uniform(0.5 - translate, 0.5 + translate) * width  # x translation (pixels)
  • 在这一行中,T[0, 2]代表矩阵T的第一行第三列,也就是x轴的平移量(translation)。random.uniform(0.5 - translate, 0.5 + translate)生成一个在给定范围内的随机浮点数,范围是0.5 - translate0.5 + translate之间。这个值乘以width后,就得到了x轴的实际平移像素值。这样做的目的是将图像在x轴上随机平移,且平移的范围与输入的translate参数和图像的宽度有关。
T[1, 2] = random.uniform(0.5 - translate, 0.5 + translate) * height  # y translation (pixels)
  • 这一行的作用与上一行类似,不过是对y轴的平移量。T[1, 2]代表矩阵T的第二行第三列,表示y轴的平移量。同样使用了random.uniform函数生成一个随机值,并乘以图像的高度height,以计算y轴的平移量。

这段代码的主要功能是构建一个用于二维图像变换的平移矩阵T。具体来说,它通过随机生成x轴和y轴的平移量,将图像在这两个方向上进行随机平移。通过使用输入的translate参数和图像的宽度与高度,确保了平移量在合适的范围内。这种平移对于数据增强(data augmentation)尤其重要,可以帮助模型更好地适应不同的图像变换,从而提升模型的泛化能力。

3.5.6 合成变换矩阵 (Combined transformation matrix)

M = T @ S @ R @ P @ C  # order of operations (right to left) is IMPORTANT

组合各个变换矩阵,通过矩阵乘法将它们结合在一起。右侧的变换会首先应用。

这行代码的目的是计算一个复合变换矩阵 M,其将一系列的变换(平移、旋转、缩放、剪切和透视)组合成一个最终的变换。这个矩阵在处理图像时非常重要,尤其是在进行图像增强时。我们来逐步分解和详细解释这行代码。

  1. 变换矩阵的顺序

    • 在计算复合变换矩阵时,操作的顺序是从右到左的。这是因为矩阵运算的顺序是先应用最后一个矩阵,再依次向前应用其他矩阵。具体来说,如果 M = A @ B,则 M 代表先应用变换 B 再应用变换 A
  2. 各个变换矩阵的含义

    • C (Center): 中心变换矩阵,负责将图像的中心移动到坐标原点((0,0))。
    • P (Perspective): 透视变换矩阵,负责应用透视效果,通过修改图像的四个角点实现。
    • R (Rotation and Scale): 旋转和缩放变换矩阵,负责对图像进行旋转和缩放处理。
    • S (Shear): 剪切变换矩阵,负责改变图像形状的斜切。
    • T (Translation): 平移变换矩阵,负责在水平和垂直方向上移动图像。
  3. 矩阵乘法的计算

    • 通过依次进行矩阵的乘法操作,最终生成一个矩阵 M,这个矩阵将整合所有变换效果。矩阵的乘法遵循线性变换的性质,最终结果将包含所有这些变换的影响。

这行代码 M = T @ S @ R @ P @ C 的主要功能是生成一个复合变换矩阵 M,它将对输入图像进行一系列的图像变换,包括透视、旋转、缩放、剪切和平移。这个复杂的操作使得图像处理中的数据增强更加灵活和有效,尤其适合于目标检测和图像分类等计算机视觉任务。通过合成多个变换,可以生成多样化的训练样本,从而提高模型的泛化能力。

3.5.7 应用变换

if (border[0] != 0) or (border[1] != 0) or (M != np.eye(3)).any():  # image changed
    if perspective:
        im = cv2.warpPerspective(im, M, dsize=(width, height), borderValue=(114, 114, 114))
    else:  # affine
        im = cv2.warpAffine(im, M[:2], dsize=(width, height), borderValue=(114, 114, 114))
  • 根据是否应用了透视变换,使用 cv2.warpPerspective 或 cv2.warpAffine 方法对图像进行变换。边界用固定值填充。

这段代码的作用是根据是否需要进行透视变换或仿射变换来处理图像。具体分解如下:

  1. 条件判断

    if (border[0] != 0) or (border[1] != 0) or (M != np.eye(3)).any():  # image changed
    
    • border[0] != 0 和 border[1] != 0:检查图像在垂直和水平方向上是否有边界填充。如果任一方向的填充不为零,意味着图像需要填充。
    • M != np.eye(3):检查变换矩阵 M 是否与单位矩阵 np.eye(3) 相等。如果不相等,意味着图像需要变换(如旋转、缩放、剪切等)。
    • .any():如果上述条件中的任一条件为真(即图像被改变),则进入该条件块。
  2. 透视变换处理

    if perspective:
        im = cv2.warpPerspective(im, M, dsize=(width, height), borderValue=(114, 114, 114))
    
    • if perspective::检查当前是否需要进行透视变换。
    • cv2.warpPerspective(...):使用 OpenCV 的 warpPerspective 函数对图像 im 进行透视变换。
      • im:要变换的图像。
      • M:变换矩阵,定义了如何变换图像。
      • dsize=(width, height):输出图像的尺寸。
      • borderValue=(114, 114, 114):在变换过程中,如果有区域需要填充,则填充的颜色值为灰色(BGR格式)。
  3. 仿射变换处理

    else:  # affine
        im = cv2.warpAffine(im, M[:2], dsize=(width, height), borderValue=(114, 114, 114))
    
    • 如果不需要透视变换,则进行仿射变换。
    • cv2.warpAffine(...):使用 OpenCV 的 warpAffine 函数对图像 im 进行仿射变换。
      • M[:2]:取变换矩阵的前两行,仿射变换只使用前两行。
      • 其他参数与透视变换相同,输出图像的大小和填充颜色一样。

这段代码的主要功能是根据是否存在边界填充和变换矩阵的变化来决定是否进行图像的透视或仿射变换。通过调用 OpenCV 相关函数,其可以有效处理图像变换,确保在图像处理的过程中,无论是添加边界填充还是变换,都能保持图像的完整性和清晰度。

3.5.8 调整目标框坐标

n = len(targets)
    if n:
        use_segments = any(x.any() for x in segments)
        new = np.zeros((n, 4))
        if use_segments:  # warp segments
            segments = resample_segments(segments)  # upsample
            for i, segment in enumerate(segments):
                xy = np.ones((len(segment), 3))
                xy[:, :2] = segment
                xy = xy @ M.T  # transform
                xy = xy[:, :2] / xy[:, 2:3] if perspective else xy[:, :2]  # perspective rescale or affine

                # clip
                new[i] = segment2box(xy, width, height)

        else:  # warp boxes
            xy = np.ones((n * 4, 3))
            xy[:, :2] = targets[:, [1, 2, 3, 4, 1, 4, 3, 2]].reshape(n * 4, 2)  # x1y1, x2y2, x1y2, x2y1
            xy = xy @ M.T  # transform
            xy = (xy[:, :2] / xy[:, 2:3] if perspective else xy[:, :2]).reshape(n, 8)  # perspective rescale or affine

            # create new boxes
            x = xy[:, [0, 2, 4, 6]]
            y = xy[:, [1, 3, 5, 7]]
            new = np.concatenate((x.min(1), y.min(1), x.max(1), y.max(1))).reshape(4, n).T

            # clip
            new[:, [0, 2]] = new[:, [0, 2]].clip(0, width)
            new[:, [1, 3]] = new[:, [1, 3]].clip(0, height)
  • 检查是否有目标框需要处理,准备一个新的坐标数组。

  • 如果使用了分段(segments),则需要对其进行变换。否则,执行简单的目标框的变换。

这段代码主要用于对目标检测任务中的目标框(bounding boxes)或分割区域(segments)进行变换和裁剪,通常是在图像执行透视变换(perspective transformation)时使用。以下是对代码的逐步分解和详细解释:

  1. 计算目标数量

    n = len(targets)
    

    这里,n 表示目标框的数量,即targets数组的长度。

  2. 检查是否有目标框

    if n:
    

    该判断用于检查是否有目标框。如果n为0,说明没有目标框,就不进行后续处理。

  3. 检查是否使用分割区域

    use_segments = any(x.any() for x in segments)
    

    这行代码检查segments中是否有任何有效的分割区域,即是否有任何非空的分割数据。

  4. 初始化新数组

    new = np.zeros((n, 4))
    

    创建一个新的数组new,大小为n x 4,用于存储变换后的目标框。

  5. 处理分割区域

    if use_segments:  # warp segments
    

    如果use_segments为True,说明需要处理分割区域。调用resample_segments函数对分割区域进行上采样(upsample),以适应变换后的尺寸。

  6. 遍历每个分割区域

    for i, segment in enumerate(segments):
    

    遍历每个分割区域,并为每个分割区域进行以下操作:

    • 创建一个全为1的数组xy,用于存储坐标。
    • 将当前分割区域segment的坐标放入xy的前两列。
    • 应用变换矩阵M,进行坐标变换。
    • 根据透视变换的形式,进行坐标的透视重新调整或仿射变换。
  7. 转换为盒子坐标

    new[i] = segment2box(xy, width, height)
    

    将变换后的坐标转换为目标框格式并存储到new数组中。

  8. 处理目标框

    else:  # warp boxes
    

    如果没有分割区域,则转而对目标框进行处理。

  9. 准备变换坐标

    xy = np.ones((n * 4, 3))
    

    创建一个n * 4 x 3的数组,以便存储每个目标框的四个点(左上、右上、右下、左下)的坐标。

  10. 填充目标框坐标

    xy[:, :2] = targets[:, [1, 2, 3, 4, 1, 4, 3, 2]].reshape(n * 4, 2)
    

    这里从targets提取目标框的坐标,并重塑为n * 4 x 2的形式。

  11. 进行坐标变换

    xy = xy @ M.T  # transform
    

    应用变换矩阵M

  12. 创建新的盒子

    x = xy[:, [0, 2, 4, 6]]
    y = xy[:, [1, 3, 5, 7]]
    new = np.concatenate((x.min(1), y.min(1), x.max(1), y.max(1))).reshape(4, n).T
    

    最后,基于变换后的坐标计算新的目标框,取每个目标框的x和y坐标的最小值和最大值。

  13. 裁剪目标框坐标

    new[:, [0, 2]] = new[:, [0, 2]].clip(0, width)
    new[:, [1, 3]] = new[:, [1, 3]].clip(0, height)
    

    对新的目标框坐标进行裁剪,确保它们不会超出图像的边界。

这段代码的主要功能是对目标框或分割区域在图像经过透视变换后进行重新变换和裁剪。它首先判断是否存在目标框或分割区域,然后根据它们的存在情况应用相应的变换逻辑,最终生成新的边界框,并确保这些边界框仍在图像范围内。此代码通常用于数据增强和目标检测任务,以提高模型的鲁棒性。

3.5.9 更新目标框坐标

i = box_candidates(box1=targets[:, 1:5].T * s, box2=new.T, area_thr=0.01 if use_segments else 0.10)
targets = targets[i]
targets[:, 1:5] = new[i]
  •  通过比较变换前后的框的候选位置,筛选有效的目标框。

这段代码主要是用于筛选和更新目标框(bounding boxes)。下面逐步分解并详细解释这段代码。

  1. box_candidates 函数的调用

    • targets[:, 1:5]:从 targets 数组中提取出目标框的坐标部分。通常这些坐标是以 (x_min, y_min, x_max, y_max) 的形式存储在 targets 中。
    • .T:对提取的框进行转置,便于后续计算(使每个框的坐标变为一列)。
    • * s:对目标框的坐标进行缩放,s 是在图像变换过程中计算出的缩放因子,通常用于确保在变换后框的大小和位置保持一致。
    • new.T:同样地,new 也是一个框的数组(可能经过变换的框),这里进行转置。
    • area_thr=0.01 if use_segments else 0.10:根据 use_segments 的值决定区域阈值。如果使用了分段(segments),则使用更小的阈值 0.01;否则使用 0.10,表示在筛选时,框的面积必须满足一定比例。

    这个函数调用的目的是计算两个框数组之间的候选框,返回符合条件的索引 i

  2. 更新 targets 的内容

    targets = targets[i]
    

    这里的 i 是一个布尔索引数组,用于筛选 targets 中符合条件的框。只有在 box_candidates 函数中确认为有效的框才会被保留。

  3. 更新框的坐标

    targets[:, 1:5] = new[i]
    

    这一行是将筛选后的 new 数组的框坐标更新到 targets 中。也就是说,符合条件的框将会被更新为 new 数组中相应的框坐标。

这段代码的主要功能是通过比较变换前后的目标框,筛选出符合特定条件的框并进行更新。首先,它检查变换后框的状况,通过计算两个框数组之间的候选框,保留那些满足面积和其他条件的框。最终,更新目标框的坐标,以便在后续处理或训练中使用。这种操作在目标检测中非常重要,用于确保在图像增强或变换后,模型仍能正确理解和定位目标。

3.6 总结

这段代码的主要功能是对输入图像进行一系列的随机变换,包括仿射变换和透视变换,同时根据变换更新目标框的坐标。这些数据增强方法可以有效提高模型的泛化能力,特别是在计算机视觉任务中,能够增强数据集的多样性,帮助模型更好地适应不同的输入场景。

四、通用函数

4.1 函数box_candidates

这个函数用在random_perspective中,是对透视变换后的图片label进行筛选,去除被裁剪过小的框(面积小于裁剪前的area_thr) 并且保留下来的框的长宽必须大于wh_thr个像素,且长宽比范围在(1/ar_thr, ar_thr)之间。

def box_candidates(box1, box2, wh_thr=2, ar_thr=100, area_thr=0.1, eps=1e-16):  # box1(4,n), box2(4,n)
    # Compute candidate boxes: box1 before augment, box2 after augment, wh_thr (pixels), aspect_ratio_thr, area_ratio
    w1, h1 = box1[2] - box1[0], box1[3] - box1[1]
    w2, h2 = box2[2] - box2[0], box2[3] - box2[1]
    ar = np.maximum(w2 / (h2 + eps), h2 / (w2 + eps))  # aspect ratio
    return (w2 > wh_thr) & (h2 > wh_thr) & (w2 * h2 / (w1 * h1 + eps) > area_thr) & (ar < ar_thr)  # candidates

这段代码定义了一个函数 box_candidates,用于计算候选框(bounding boxes),以评估在图像增强过程中源框(box1)和目标框(box2)的合适性。以下是对代码的逐步解析:

  1. 函数定义和参数

    def box_candidates(box1, box2, wh_thr=2, ar_thr=100, area_thr=0.1, eps=1e-16):
    
    • box1 和 box2 :这两个参数分别表示在增强前和增强后的框,格式为 (4, n) 的数组,其中每个框由四个值定义(x_min, y_min, x_max, y_max)。
    • wh_thr :宽和高的阈值,框的宽度和高度必须大于这个值才能被认为是候选框。
    • ar_thr :宽高比的阈值,候选框的宽高比必须小于这个值。
    • area_thr :面积比阈值,框的面积必须大于输入框的一定比例。
    • eps :一个小的常数,用于防止在计算中出现除零错误。
  2. 计算框的宽和高

    w1, h1 = box1[2] - box1[0], box1[3] - box1[1]
    w2, h2 = box2[2] - box2[0], box2[3] - box2[1]
    
    • 计算 box1 和 box2 的宽度和高度。w1 和 h1 是 box1 的宽和高,w2 和 h2 是 box2 的宽和高。
  3. 计算宽高比

    ar = np.maximum(w2 / (h2 + eps), h2 / (w2 + eps))  # aspect ratio
    
    • 计算 box2 的宽高比。使用 np.maximum 函数确保在计算中不会出现除零错误。
  4. 返回符合条件的候选框

    return (w2 > wh_thr) & (h2 > wh_thr) & (w2 * h2 / (w1 * h1 + eps) > area_thr) & (ar < ar_thr)
    
    • 这个条件返回一个布尔数组,指示哪些 box2 是合格的候选框。候选框需要满足:
      • 宽度和高度都大于 wh_thr
      • box2 的面积大于 box1 的面积的一定比例。
      • box2 的宽高比小于 ar_thr

box_candidates 函数用于筛选出在图像增强过程中生成的目标框(box2)中所有合适的候选框。它基于宽度、高度、面积比和宽高比这几个条件来判断框的有效性。其主要功能是帮助增强后的框与原始框进行比较,以确保生成的框符合一定的标准,从而提高后续处理(例如目标检测算法)的准确性。

4.2. replicate

这个函数是随机偏移标签中心,生成新的标签与原标签结合。可以用在load_mosaic里的mosaic操作之后 以及random_perspective操作之前, 作者默认是关闭的。

def replicate(im, labels):
    # Replicate labels
    h, w = im.shape[:2]
    boxes = labels[:, 1:].astype(int)
    x1, y1, x2, y2 = boxes.T
    s = ((x2 - x1) + (y2 - y1)) / 2  # side length (pixels)
    for i in s.argsort()[:round(s.size * 0.5)]:  # smallest indices
        x1b, y1b, x2b, y2b = boxes[i]
        bh, bw = y2b - y1b, x2b - x1b
        yc, xc = int(random.uniform(0, h - bh)), int(random.uniform(0, w - bw))  # offset x, y
        x1a, y1a, x2a, y2a = [xc, yc, xc + bw, yc + bh]
        im[y1a:y2a, x1a:x2a] = im[y1b:y2b, x1b:x2b]  # im4[ymin:ymax, xmin:xmax]
        labels = np.append(labels, [[labels[i, 0], x1a, y1a, x2a, y2a]], axis=0)

    return im, labels

 这段代码的功能是对输入图像进行标签的复制(replication),也就是说,它可以在图像中随机复制一些物体,使得这些物体的标签也相应地更新。让我们逐步分解这段代码并详细解释每个部分。

  1. 函数定义与参数:

    def replicate(im, labels):
    
    • im:输入图像,通常是一个NumPy数组,形状为(高度,宽度,通道数)。
    • labels:物体检测标签,形状通常为(n, 5),每一行表示一个物体的类(class)和其边界框坐标(x1, y1, x2, y2)。
  2. 获取图像的高度和宽度:

    h, w = im.shape[:2]
    
    • 这里提取出图像的高度(h)和宽度(w),用于后面的计算。
  3. 提取边界框坐标:

    boxes = labels[:, 1:].astype(int)
    x1, y1, x2, y2 = boxes.T
    
    • 将标签中的边界框坐标提取出来,boxes 只包含边界框的坐标(不包括类标签)。
    • x1, y1, x2, y2 分别是边界框的左上角和右下角坐标。
  4. 计算每个边界框的大小:

    s = ((x2 - x1) + (y2 - y1)) / 2  # side length (pixels)
    
    • 计算每个边界框的大小,s 是边界框的平均宽度和高度,用于决定哪个边界框较小。
  5. 复制较小的边界框:

    for i in s.argsort()[:round(s.size * 0.5)]:  # smallest indices
    
    • 通过 argsort() 获取边界框大小的排序索引,并选择最小的一半进行复制。
  6. 随机选择复制位置:

    x1b, y1b, x2b, y2b = boxes[i]
    bh, bw = y2b - y1b, x2b - x1b
    yc, xc = int(random.uniform(0, h - bh)), int(random.uniform(0, w - bw))  # offset x, y
    x1a, y1a, x2a, y2a = [xc, yc, xc + bw, yc + bh]
    
    • 对于每一个被选择的边界框,计算其高度(bh)和宽度(bw)。
    • 使用 random.uniform() 随机生成新的位置(xc, yc),确保新的边界框不超出图像边界。
    • 使用新的位置生成新的边界框坐标(x1a, y1a, x2a, y2a)。
  7. 在图像中复制区域:

    im[y1a:y2a, x1a:x2a] = im[y1b:y2b, x1b:x2b]  # im4[ymin:ymax, xmin:xmax]
    
    • 在图像中用新位置的边界框替换为原始边界框的像素。
  8. 更新标签:

    labels = np.append(labels, [[labels[i, 0], x1a, y1a, x2a, y2a]], axis=0)
    
    • 将新创建的边界框的标签(类标签和新边界框坐标)添加到 labels 中。
  9. 返回更新后的图像和标签:

    return im, labels
    

该代码的主要功能是通过随机复制输入图像中某些物体的区域来增强数据集。它在图像上的随机位置创建了物体的复制,并在标签中相应地增加了新物体的边界框。这样可以增加训练样本的多样性,适用于数据增强策略,特别是在深度学习中的物体检测任务中。

4.3 letterbox 

YOLOV5中的自适应图片缩放 letterbox 保持图片的宽高比例,剩下的部分用灰色填充。

letterbox 的img转换部分

此时:auto=False(需要pad), scale_fill=False, scale_up=False。

显然,这部分需要缩放,因为在这之前的load_image部分已经缩放过了(最长边等于指定大小,较短边等比例缩放),那么在letterbox只需要计算出较小边需要填充的pad, 再将较小边两边pad到相应大小(每个batch需要每张图片的大小,这个大小是不相同的)即可。

def letterbox(im, new_shape=(640, 640), color=(114, 114, 114), auto=True, scaleFill=False, scaleup=True, stride=32):
    # Resize and pad image while meeting stride-multiple constraints
    shape = im.shape[:2]  # current shape [height, width]
    if isinstance(new_shape, int):
        new_shape = (new_shape, new_shape)

    # Scale ratio (new / old)
    r = min(new_shape[0] / shape[0], new_shape[1] / shape[1])
    if not scaleup:  # only scale down, do not scale up (for better val mAP)
        r = min(r, 1.0)

    # Compute padding
    ratio = r, r  # width, height ratios
    new_unpad = int(round(shape[1] * r)), int(round(shape[0] * r))
    dw, dh = new_shape[1] - new_unpad[0], new_shape[0] - new_unpad[1]  # wh padding
    if auto:  # minimum rectangle
        dw, dh = np.mod(dw, stride), np.mod(dh, stride)  # wh padding
    elif scaleFill:  # stretch
        dw, dh = 0.0, 0.0
        new_unpad = (new_shape[1], new_shape[0])
        ratio = new_shape[1] / shape[1], new_shape[0] / shape[0]  # width, height ratios

    dw /= 2  # divide padding into 2 sides
    dh /= 2

    if shape[::-1] != new_unpad:  # resize
        im = cv2.resize(im, new_unpad, interpolation=cv2.INTER_LINEAR)
    top, bottom = int(round(dh - 0.1)), int(round(dh + 0.1))
    left, right = int(round(dw - 0.1)), int(round(dw + 0.1))
    im = cv2.copyMakeBorder(im, top, bottom, left, right, cv2.BORDER_CONSTANT, value=color)  # add border
    return im, ratio, (dw, dh)

该代码实现了一个函数 letterbox,用于调整图像的大小并填充,使其满足特定的形状要求,同时确保满足某些条件,比如步幅(stride)要求。以下是对该函数的逐步分解与详细解释:

  1. 函数定义与参数

    def letterbox(im, new_shape=(640, 640), color=(114, 114, 114), auto=True, scaleFill=False, scaleup=True, stride=32):
    
    • im:输入图像。
    • new_shape:目标形状,默认为 (640, 640)。如果传入一个整数,则将其视为宽和高的相等值。
    • color:填充颜色,默认为 (114, 114, 114)。
    • auto:布尔值,用于决定是否使用最小矩形填充,默认为 True
    • scaleFill:布尔值,决定是否拉伸到目标大小,默认为 False
    • scaleup:布尔值,决定是否允许放大图像,默认为 True
    • stride:用作步幅的整数,默认为 32。
  2. 获取当前图像形状

    shape = im.shape[:2]  # current shape [height, width]
    
    • 获取输入图像的高度和宽度。
  3. 处理新形状

    if isinstance(new_shape, int):
        new_shape = (new_shape, new_shape)
    
    • 如果 new_shape 是整数,则将其转化为一个二元组 (height, width)。
  4. 计算缩放比例

    r = min(new_shape[0] / shape[0], new_shape[1] / shape[1])
    if not scaleup:  # only scale down, do not scale up (for better val mAP)
        r = min(r, 1.0)
    
    • 计算新旧尺寸的缩放比例 r。如果 scaleup 为 False,则只允许缩小图像。
  5. 计算填充

    ratio = r, r  # width, height ratios
    new_unpad = int(round(shape[1] * r)), int(round(shape[0] * r))
    dw, dh = new_shape[1] - new_unpad[0], new_shape[0] - new_unpad[1]  # wh padding
    
    • 根据计算的比例调整图像的大小,然后计算需要填充的宽和高。
  6. 自动和伸展填充的处理

    if auto:  # minimum rectangle
        dw, dh = np.mod(dw, stride), np.mod(dh, stride)  # wh padding
    elif scaleFill:  # stretch
        dw, dh = 0.0, 0.0
        new_unpad = (new_shape[1], new_shape[0])
        ratio = new_shape[1] / shape[1], new_shape[0] / shape[0]  # width, height ratios
    
    • 如果 auto 为 True,则调整填充值以保证是步幅的倍数;如果 scaleFill 为 True,则缩放填充为零,并直接将图像调整至目标尺寸。
  7. 调整填充至两侧

    dw /= 2  # divide padding into 2 sides
    dh /= 2
    
  8. 调整图像尺寸并填充

    if shape[::-1] != new_unpad:  # resize
        im = cv2.resize(im, new_unpad, interpolation=cv2.INTER_LINEAR)
    top, bottom = int(round(dh - 0.1)), int(round(dh + 0.1))
    left, right = int(round(dw - 0.1)), int(round(dw + 0.1))
    im = cv2.copyMakeBorder(im, top, bottom, left, right, cv2.BORDER_CONSTANT, value=color)  # add border
    
    • 若原图形状与调整后的新形状不一致,则使用 cv2.resize 进行调整,并使用 cv2.copyMakeBorder 增加边框。
  9. 返回结果

    return im, ratio, (dw, dh)
    
  • 返回调整后的图像、缩放比例、以及填充值。

该函数的主要功能是将输入图像调整为指定的形状,并在必要时填充边界以符合特定的步幅要求。它可以在保持图像比例的前提下调整图像规模,支持自动计算填充,并且在设置了 scaleFill 的情况下,可以拉伸图像到目标尺寸。总之,这个函数对于准备输入给深度学习模型的图像非常有用,确保图像形状及其定位的准确性。

总结下在val.py数据加载部分主要是做了三件事:

  1. load_image将图片从文件中加载出来,并resize到相应的尺寸(最长边等于我们需要的尺寸,最短边等比例缩放);
  2. letterbox将之前resize后的图片再pad到我们所需要的放到dataloader中(collate_fn函数)的尺寸(矩形训练要求同一个 batch中的图片的尺寸必须保持一致);
  3. 将label从相对原图尺寸(原文件中图片尺寸)缩放到相对letterbox pad后的图片尺寸。因为前两部分的图片尺寸发生了变化,同样的我们的label也需要发生相应的变化。

4.4. cutout

cutout数据增强,给图片随机添加随机大小的方块噪声 ,目的是提高泛化能力和鲁棒性。源自论文: Improved Regularization of Convolutional Neural Networks with Cutout 。

def cutout(im, labels, p=0.5):
    # Applies image cutout augmentation https://arxiv.org/abs/1708.04552
    if random.random() < p:
        h, w = im.shape[:2]
        scales = [0.5] * 1 + [0.25] * 2 + [0.125] * 4 + [0.0625] * 8 + [0.03125] * 16  # image size fraction
        for s in scales:
            mask_h = random.randint(1, int(h * s))  # create random masks
            mask_w = random.randint(1, int(w * s))

            # box
            xmin = max(0, random.randint(0, w) - mask_w // 2)
            ymin = max(0, random.randint(0, h) - mask_h // 2)
            xmax = min(w, xmin + mask_w)
            ymax = min(h, ymin + mask_h)

            # apply random color mask
            im[ymin:ymax, xmin:xmax] = [random.randint(64, 191) for _ in range(3)]

            # return unobscured labels
            if len(labels) and s > 0.03:
                box = np.array([xmin, ymin, xmax, ymax], dtype=np.float32)
                ioa = bbox_ioa(box, xywhn2xyxy(labels[:, 1:5], w, h))  # intersection over area
                labels = labels[ioa < 0.60]  # remove >60% obscured labels

    return labels

这段代码是实现图像增强中的“cutout”技术。以下是对代码的逐步分解和详细解释:

  1. 函数定义

    def cutout(im, labels, p=0.5):
    
    • im: 输入图像,通常为一个numpy数组。
    • labels: 目标框的标签,通常是一个包含目标类别和对应边界框坐标的数组。
    • p: 一个浮点数,表示cutout操作的概率(默认值为0.5)。
  2. 随机决定是否应用cutout

    if random.random() < p:
    

    这里通过生成一个随机数来决定是否应用cutout技术。如果生成的随机数小于p,则继续执行cutout操作。

  3. 获取图像的尺寸

    h, w = im.shape[:2]
    
    • hw分别是图像的高度和宽度。
  4. 定义多种缩放比例

    scales = [0.5] * 1 + [0.25] * 2 + [0.125] * 4 + [0.0625] * 8 + [0.03125] * 16
    
    • 该段代码定义了多个缩放因子,用于确定cutout区域的尺寸,范围从50%到3.125%。
  5. 循环遍历缩放比例

    for s in scales:
    
    • 对每一个缩放比例s,生成相应的随机遮罩。
  6. 生成随机遮罩的高度和宽度

    mask_h = random.randint(1, int(h * s))
    mask_w = random.randint(1, int(w * s))
    
    • 这里根据当前的缩放比例随机生成一个遮罩的高度和宽度。
  7. 计算遮罩的位置

    xmin = max(0, random.randint(0, w) - mask_w // 2)
    ymin = max(0, random.randint(0, h) - mask_h // 2)
    xmax = min(w, xmin + mask_w)
    ymax = min(h, ymin + mask_h)
    
    • xminymin是遮罩左上角的坐标,xmaxymax是右下角坐标,通过随机生成的位置确保遮罩不会超出图像边界。
  8. 应用随机颜色遮罩

    im[ymin:ymax, xmin:xmax] = [random.randint(64, 191) for _ in range(3)]
    
    • 用随机 RGB 颜色来覆盖所选区域。颜色的值在64到191之间,确保不全白或全黑,避免对比度过低。
  9. 处理标签以去除被遮蔽的对象

    if len(labels) and s > 0.03:
        box = np.array([xmin, ymin, xmax, ymax], dtype=np.float32)
        ioa = bbox_ioa(box, xywhn2xyxy(labels[:, 1:5], w, h))  # intersection over area
        labels = labels[ioa < 0.60]  # remove >60% obscured labels
    
    • 如果标签存在并且当前的缩放比例大于0.03,就会计算遮罩区域与目标框之间的重叠面积。如果一个目标框被遮挡超过60%,则将其从labels中移除。
  10. 返回更新后的标签

    return labels
    
    • 函数返回的是未被遮挡超过60%的目标框标签。

这段代码实现了“cutout”图像增强技术,主要功能是通过随机生成遮罩并在图像上覆盖颜色,以增加模型的鲁棒性。这样做的目的是模拟部分目标被遮挡的场景,从而提升目标检测模型在真实环境中的性能。通过检查遮罩区域与目标框之间的重叠程度,可以有效去除被遮挡明显的目标框,以确保训练数据的质量。

4.5. mixup

4.5.1 MixUp增强原理及实现

这个函数是进行mixup数据增强:按比例融合两张图片。论文:https://arxiv.org/pdf/1710.09412.pdf

Mixup数据增强核心思想是从每个Batch中随机选择两张图片,并以一定比例混合生成新的图像,训练过程全部采用混合的新图像训练,原始图像不再参与训练。

假设图像1坐标为(xi,yi),图像2坐标为(xj,yj),混合图像坐标为(x',y'),则混合公式如下: 

4.5.2 YOLOV5里代码解析

def mixup(im, labels, im2, labels2):
    # Applies MixUp augmentation https://arxiv.org/pdf/1710.09412.pdf
    r = np.random.beta(32.0, 32.0)  # mixup ratio, alpha=beta=32.0
    im = (im * r + im2 * (1 - r)).astype(np.uint8)
    labels = np.concatenate((labels, labels2), 0)
    return im, labels

这段代码实现了图像增强中的 MixUp 方法。以下是对代码的逐行分解和详细解释:

def mixup(im, labels, im2, labels2):
  • 这是一个名为 mixup 的函数,它接收四个参数:
    • im: 第一幅图像。
    • labels: 第一幅图像的标签(边界框信息)。
    • im2: 第二幅图像。
    • labels2: 第二幅图像的标签。
    # Applies MixUp augmentation https://arxiv.org/pdf/1710.09412.pdf
  • 这是一条注释,说明此函数实现的是 MixUp 增强方法,并提供了相关学术论文的链接。
    r = np.random.beta(32.0, 32.0)  # mixup ratio, alpha=beta=32.0
  • 使用 NumPy 生成一个随机数 r,该随机数服从 beta 分布,其中参数 alpha 和 beta 都为 32.0。
  • 这个随机数用于控制两幅图像的混合比例,范围在 0 到 1 之间。
    im = (im * r + im2 * (1 - r)).astype(np.uint8)
  • 这里进行混合操作:
    • 将 im 乘以比例 r,而 im2 乘以 1 - r。这意味着如果 r 接近 1,im 的贡献将大于 im2,反之亦然。
    • astype(np.uint8) 将结果转换为无符号 8 位整数格式,以符合图像数据的标准格式。
    labels = np.concatenate((labels, labels2), 0)
  • 这行代码将两个标签数组 labels 和 labels2 进行拼接(沿着第一个维度),形成一个包含所有标签的新数组。
    return im, labels
  • 最后,函数返回混合后的图像 im 和拼接后的标签 labels

该代码实现了 MixUp 数据增强技术。MixUp 是一种图像增强方法,它通过将两幅图像及其对应标签按照一定的比例进行线性混合来生成新图像。其主要功能是增加训练数据的多样性,提高模型的泛化能力。通过这种方式,MixUp 可以有效地缓解过拟合并增加数据集的表达能力。

 参考:

  1. 深度学习数据增强方法,透视变换原理以及实例代码详解
  2. 第九篇—仿射变换
  3. 第十篇—单应性变换
  4. 第十篇—数据增强(YOLOv5专题)
  5. 仿射变换及其变换矩阵的理解
  6. 第九篇—Mosaic数据增强(YOLOv5专题)
  7. 第十一篇—Mixup数据增强(YOLOv5专题)
  8. YOLOv5系列(二十) 解析数据增强部分augmentations(详尽)
  9. 深度学习中小知识点系列(三) 解读Mosaic 数据增强
  10. 深度学习中小知识点系列(二) 解读仿射变换和透视变换
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值