【從零開始學習YOLOv3】3.YOLOv3的數據組織和處理

前言:本文主要講YOLOv3中數據加載部分,主要解析的代碼在utils/datasets.py文件中。通過對數據組織、加載、處理部分代碼進行解讀,能幫助我們更快地理解YOLOv3所要求的數據輸出要求,也將有利于對之后訓練部分代碼進行理解。

1. 標注格式

在上一篇【從零開始學習YOLOv3】2. YOLOv3中的代碼配置和數據集構建 中,使用到了voc_label.py,其作用是將xml文件轉成txt文件格式,具體文件如下:

# class id, x, y, w, h
0 0.8604166666666666 0.5403899721448469 0.058333333333333334 0.055710306406685235

其中的x,y 的意義是歸一化以后的框的中心坐標,w,h是歸一化后的框的寬和高。

具體的歸一化方式為:

def convert(size, box):
    '''
    size是圖片的長和寬
    box是xmin,xmax,ymin,ymax坐標值
    '''
    dw = 1. / (size[0])
    dh = 1. / (size[1])
    # 得到長和寬的縮放比
    x = (box[0] + box[1])/2.0 - 1
    y = (box[2] + box[3])/2.0 - 1
    w = box[1] - box[0]
    h = box[3] - box[2]
    # 分別計算中心點坐標,框的寬和高
    x = x * dw
    w = w * dw
    y = y * dh
    h = h * dh
    # 按照圖片長和寬進行歸一化
    return (x,y,w,h)

可以看出,歸一化都是相對于圖片的寬和高進行歸一化的。

2. 調用

下邊是train.py文件中的有關數據的調用:

# Dataset
dataset = LoadImagesAndLabels(train_path, img_size, batch_size,
                              augment=True,
                              hyp=hyp,  # augmentation hyperparameters
                              rect=opt.rect,  # rectangular training
                              cache_labels=True,
                              cache_images=opt.cache_images)

batch_size = min(batch_size, len(dataset))

# 使用多少個線程加載數據集
nw = min([os.cpu_count(), batch_size if batch_size > 1 else 0, 1])  

dataloader = DataLoader(dataset,
                        batch_size=batch_size,
                        num_workers=nw,
                        shuffle=not opt.rect,  
                        # Shuffle=True 
                        #unless rectangular training is used
                        pin_memory=True,
                        collate_fn=dataset.collate_fn)

在pytorch中,數據集加載主要是重構datasets類,然后再使用dataloader中加載dataset,就構建好了數據部分。

下面是一個簡單的使用模板:

import os
from torch.utils.data import Dataset
from torch.utils.data import DataLoader

# 根據自己的數據集格式進行重構
class MyDataset(Dataset):
    def __init__(self):
        #下載數據、初始化數據,都可以在這里完成
        xy = np.loadtxt('label.txt', delimiter=',', dtype=np.float32) 
        # 使用numpy讀取數據
        self.x_data = torch.from_numpy(xy[:, 0:-1])
        self.y_data = torch.from_numpy(xy[:, [-1]])
        self.len = xy.shape[0]
    
    def __getitem__(self, index):
        # dataloader中使用該方法,通過index進行訪問
        return self.x_data[index], self.y_data[index]

    def __len__(self):
        # 查詢數據集中數量,可以通過len(mydataset)得到
        return self.len

# 實例化這個類,然后我們就得到了Dataset類型的數據,記下來就將這個類傳給DataLoader,就可以了。 
myDataset = MyDataset()

# 構建dataloader
train_loader = DataLoader(dataset=myDataset,
                          batch_size=32,
                          shuffle=True)

for epoch in range(2):
    for i, data in enumerate(train_loader2):
        # 將數據從 train_loader 中讀出來,一次讀取的樣本數是32個
        inputs, labels = data
        # 將這些數據轉換成Variable類型
        inputs, labels = Variable(inputs), Variable(labels)
        # 模型訓練...

通過以上模板就能大致了解pytorch中的數據加載機制,下面開始介紹YOLOv3中的數據加載。

3. YOLOv3中的數據加載

下面解析的是LoadImagesAndLabels類中的幾個主要的函數:

3.1 init函數

init函數中包含了大部分需要處理的數據

class LoadImagesAndLabels(Dataset):  # for training/testing
    def __init__(self,
                 path,
                 img_size=416,
                 batch_size=16,
                 augment=False,
                 hyp=None,
                 rect=False,
                 image_weights=False,
                 cache_labels=False,
                 cache_images=False):
        path = str(Path(path))  # os-agnostic
        assert os.path.isfile(path), 'File not found %s. See %s' % (path,
                                                                    help_url)
        with open(path, 'r') as f:
            self.img_files = [
                x.replace('/', os.sep)
                for x in f.read().splitlines()  # os-agnostic
                if os.path.splitext(x)[-1].lower() in img_formats
            ]
        # img_files是一個list,保存的是圖片的路徑

        n = len(self.img_files)
        assert n > 0, 'No images found in %s. See %s' % (path, help_url)
        bi = np.floor(np.arange(n) / batch_size).astype(np.int)  # batch index
        # 如果n=10, batch=2, bi=[0,0,1,1,2,2,3,3,4,4]
        nb = bi[-1] + 1  # 最多有多少個batch

        self.n = n
        self.batch = bi  # 圖片的batch索引,代表第幾個batch的圖片
        self.img_size = img_size
        self.augment = augment
        self.hyp = hyp
        self.image_weights = image_weights # 是否選擇根據權重進行采樣
        self.rect = False if image_weights else rect 
        # 如果選擇根據權重進行采樣,將無法使用矩形訓練:
        # 具體內容見下文

        # 標簽文件是通過images替換為labels, .jpg替換為.txt得到的。
        self.label_files = [
            x.replace('images',
                      'labels').replace(os.path.splitext(x)[-1], '.txt')
            for x in self.img_files
        ]

        # 矩形訓練具體內容見下文解析
        if self.rect:
            # 獲取圖片的長和寬 (wh)
            sp = path.replace('.txt', '.shapes')  
            # 字符串替換
            # shapefile path
            try:
                with open(sp, 'r') as f:  # 讀取shape文件
                    s = [x.split() for x in f.read().splitlines()]
                    assert len(s) == n, 'Shapefile out of sync'
            except:
                s = [
                    exif_size(Image.open(f))
                    for f in tqdm(self.img_files, desc='Reading image shapes')
                ]
                np.savetxt(sp, s, fmt='%g')  # overwrites existing (if any)

            # 根據長寬比進行排序
            s = np.array(s, dtype=np.float64)
            ar = s[:, 1] / s[:, 0]  # aspect ratio
            i = ar.argsort()

            # 根據順序重排順序
            self.img_files = [self.img_files[i] for i in i]
            self.label_files = [self.label_files[i] for i in i]
            self.shapes = s[i]  # wh
            ar = ar[i]

            # 設置訓練的圖片形狀
            shapes = [[1, 1]] * nb
            for i in range(nb):
                ari = ar[bi == i]
                mini, maxi = ari.min(), ari.max()
                if maxi < 1:
                    shapes[i] = [maxi, 1]
                elif mini > 1:
                    shapes[i] = [1, 1 / mini]

            self.batch_shapes = np.ceil(
                np.array(shapes) * img_size / 32.).astype(np.int) * 32

        # 預載標簽
        # weighted CE 訓練時需要這個步驟
        # 否則無法按照權重進行采樣
        self.imgs = [None] * n
        self.labels = [None] * n
        if cache_labels or image_weights:  # cache labels for faster training
            self.labels = [np.zeros((0, 5))] * n
            extract_bounding_boxes = False
            create_datasubset = False
            pbar = tqdm(self.label_files, desc='Caching labels')
            nm, nf, ne, ns, nd = 0, 0, 0, 0, 0  # number missing, found, empty, datasubset, duplicate
            for i, file in enumerate(pbar):
                try:
                    # 讀取每個文件內容
                    with open(file, 'r') as f:
                        l = np.array(
                            [x.split() for x in f.read().splitlines()],
                            dtype=np.float32)
                except:
                    nm += 1  # print('missing labels for image %s' % self.img_files[i])  # file missing
                    continue

                if l.shape[0]:
                    # 判斷文件內容是否符合要求
                    # 所有的值需要>0, <1, 一共5列
                    assert l.shape[1] == 5, '> 5 label columns: %s' % file
                    assert (l >= 0).all(), 'negative labels: %s' % file
                    assert (l[:, 1:] <= 1).all(
                    ), 'non-normalized or out of bounds coordinate labels: %s' % file
                    if np.unique(
                            l, axis=0).shape[0] < l.shape[0]:  # duplicate rows
                        nd += 1  # print('WARNING: duplicate rows in %s' % self.label_files[i])  # duplicate rows

                    self.labels[i] = l
                    nf += 1  # file found

                    # 創建一個小型的數據集進行試驗                    
                    if create_datasubset and ns < 1E4:
                        if ns == 0:
                            create_folder(path='./datasubset')
                            os.makedirs('./datasubset/images')
                        exclude_classes = 43
                        if exclude_classes not in l[:, 0]:
                            ns += 1
                            # shutil.copy(src=self.img_files[i], dst='./datasubset/images/')  # copy image
                            with open('./datasubset/images.txt', 'a') as f:
                                f.write(self.img_files[i] + '\n')

                    # 為兩階段分類器提取目標檢測的檢測框
                    # 默認開關是關掉的,不是很理解
                    if extract_bounding_boxes:
                        p = Path(self.img_files[i])
                        img = cv2.imread(str(p))
                        h, w = img.shape[:2]
                        for j, x in enumerate(l):
                            f = '%s%sclassifier%s%g_%g_%s' % (p.parent.parent,
                                                              os.sep, os.sep,
                                                              x[0], j, p.name)
                            if not os.path.exists(Path(f).parent):
                                os.makedirs(Path(f).parent)  
                                # make new output folder

                            b = x[1:] * np.array([w, h, w, h])  # box
                            b[2:] = b[2:].max()  # rectangle to square
                            b[2:] = b[2:] * 1.3 + 30  # pad

                            b = xywh2xyxy(b.reshape(-1,4)).ravel().astype(np.int)

                            b[[0,2]] = np.clip(b[[0, 2]], 0,w)  # clip boxes outside of image
                            b[[1, 3]] = np.clip(b[[1, 3]], 0, h)
                            assert cv2.imwrite(f, img[b[1]:b[3], b[0]:b[2]]), 'Failure extracting classifier boxes'
                else:
                    ne += 1

                pbar.desc = 'Caching labels (%g found, %g missing, %g empty, %g duplicate, for %g images)' 
                % (nf, nm, ne, nd, n) # 統計發現,丟失,空,重復標簽的數量。
            assert nf > 0, 'No labels found. See %s' % help_url

        # 將圖片加載到內存中,可以加速訓練
        # 警告:如果在數據比較多的情況下可能會超出RAM
        if cache_images:  # if training
            gb = 0  # 計算緩存到內存中的圖片占用的空間GB為單位
            pbar = tqdm(range(len(self.img_files)), desc='Caching images')
            self.img_hw0, self.img_hw = [None] * n, [None] * n
            for i in pbar:  # max 10k images
                self.imgs[i], self.img_hw0[i], self.img_hw[i] = load_image(
                    self, i)  # img, hw_original, hw_resized
                gb += self.imgs[i].nbytes
                pbar.desc = 'Caching images (%.1fGB)' % (gb / 1E9)

        # 刪除損壞的文件
        # 根據需要進行手動開關
        detect_corrupted_images = False
        if detect_corrupted_images:
            from skimage import io  # conda install -c conda-forge scikit-image
            for file in tqdm(self.img_files,
                             desc='Detecting corrupted images'):
                try:
                    _ = io.imread(file)
                except:
                    print('Corrupted image detected: %s' % file)

Rectangular inference(矩形推理)

  1. 矩形推理是在detect.py,也就是測試過程中的實現,可以減少推理時間。YOLOv3中是下采樣32倍,長寬也必須是32的倍數,所以在進入模型前,數據需要處理到416×416大小,這個過程稱為仿射變換,如果用opencv實現可以用以下代碼:
# 來自 https://zhuanlan.zhihu.com/p/93822508
def cv2_letterbox_image(image, expected_size):
    ih, iw = image.shape[0:2]
    ew, eh = expected_size
    scale = min(eh / ih, ew / iw)
    nh = int(ih * scale)
    nw = int(iw * scale)
    image = cv2.resize(image, (nw, nh), interpolation=cv2.INTER_CUBIC)
    top = (eh - nh) // 2
    bottom = eh - nh - top
    left = (ew - nw) // 2
    right = ew - nw - left
    new_img = cv2.copyMakeBorder(image, top, bottom, left, right, cv2.BORDER_CONSTANT)
    return new_img

比如下圖是一個h>w,一個是w>h的圖片經過仿射變換后resize到416×416的示例:

image

以上就是正方形推理,但是可以看出以上通過補充得到的結果會存在很多冗余信息,而Rectangular Training思路就是想要去掉這些冗余的部分。

具體過程為:求得較長邊縮放到416的比例,然后對圖片w:h按這個比例縮放,使得較長邊達到416,再對較短邊進行盡量少的填充使得較短邊滿足32的倍數。

示例如下:

image

Rectangular Training(矩形訓練)

很自然的,訓練的過程也可以用到這個想法,減少冗余。不過訓練的時候情況比較復雜,由于在訓練過程中是一個batch的圖片,而每個batch圖片是有可能長寬比不同的,這就是與測試最大的區別。具體是實現是取這個batch中最大的場合寬,然后將整個batch中填充到max width和max height,這樣操作對小一些的圖片來說也是比較浪費。這里的yolov3的實現主要就是優化了一下如何將比例相近的圖片放在一個batch,這樣顯然填充的就更少一些了。作者在issue中提到,在coco數據集中使用這個策略進行訓練,能夠快1/3。

而如果選擇開啟矩形訓練,必須要關閉dataloader中的shuffle參數,防止對數據的順序進行調整。同時如果選擇image_weights, 根據圖片進行采樣,也無法與矩陣訓練同時使用。

3.2 getitem函數

    def __getitem__(self, index):
        # 新的下角標
        if self.image_weights:
            index = self.indices[index]

        img_path = self.img_files[index]
        label_path = self.label_files[index]

        hyp = self.hyp
        mosaic = True and self.augment
        # 如果開啟鑲嵌增強、數據增強
        # 加載四張圖片,作為一個鑲嵌,具體看下文解析。
        if mosaic:
            # 加載鑲嵌內容
            img, labels = load_mosaic(self, index)
            shapes = None

        else:
            # 加載圖片
            img, (h0, w0), (h, w) = load_image(self, index)

            # 仿射變換
            shape = self.batch_shapes[self.batch[
                index]] if self.rect else self.img_size 
            img, ratio, pad = letterbox(img,
                                        shape,
                                        auto=False,
                                        scaleup=self.augment)
            shapes = (h0, w0), (
                (h / h0, w / w0), pad)  

            # 加載標注文件
            labels = []
            if os.path.isfile(label_path):
                x = self.labels[index]
                if x is None:  # 如果標簽沒有加載,讀取label_path內容
                    with open(label_path, 'r') as f:
                        x = np.array(
                            [x.split() for x in f.read().splitlines()],
                            dtype=np.float32)

                if x.size > 0:
                    # 將歸一化后的xywh轉化為左上角、右下角的表達形式
                    labels = x.copy()
                    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:
            # 圖片空間的數據增強
            if not mosaic:
                # 如果沒有使用鑲嵌的方法,那么對圖片進行隨機放射
                img, labels = random_affine(img,
                                            labels,
                                            degrees=hyp['degrees'],
                                            translate=hyp['translate'],
                                            scale=hyp['scale'],
                                            shear=hyp['shear'])

            # 增強hsv空間
            augment_hsv(img,
                        hgain=hyp['hsv_h'],
                        sgain=hyp['hsv_s'],
                        vgain=hyp['hsv_v'])

        nL = len(labels)  # 標注文件個數

        if nL:
            # 將 xyxy 格式轉化為 xywh 格式
            labels[:, 1:5] = xyxy2xywh(labels[:, 1:5])

            # 歸一化到0-1之間
            labels[:, [2, 4]] /= img.shape[0]  # height
            labels[:, [1, 3]] /= img.shape[1]  # width

        if self.augment:
            # 隨機左右翻轉
            lr_flip = True
            if lr_flip and random.random() < 0.5:
                img = np.fliplr(img)
                if nL:
                    labels[:, 1] = 1 - labels[:, 1]

            # 隨機上下翻轉
            ud_flip = False
            if ud_flip and random.random() < 0.5:
                img = np.flipud(img)
                if nL:
                    labels[:, 2] = 1 - labels[:, 2]

        labels_out = torch.zeros((nL, 6))
        if nL:
            labels_out[:, 1:] = torch.from_numpy(labels)

        # 圖像維度轉換
        img = img[:, :, ::-1].transpose(2, 0, 1)  # BGR to RGB, to 3x416x416
        img = np.ascontiguousarray(img)

        return torch.from_numpy(img), labels_out, img_path, shapes

下圖是開啟了鑲嵌和旋轉以后的增強效果(mosaic不知道翻譯的對不對,如果有問題,歡迎指正。)

這里理解鑲嵌就是將四張圖片,以不同的比例,合成為一張圖片。

image

3.3 collate_fn函數

    @staticmethod
    def collate_fn(batch):
        img, label, path, shapes = zip(*batch)  # transposed
        for i, l in enumerate(label):
            l[:, 0] = i  # add target image index for build_targets()
        return torch.stack(img, 0), torch.cat(label, 0), path, shapes

還有最后一點內容,是關于pytorch的數據讀取機制,本人曾經單純的認為dataloader僅僅是通過調用__getitem__(self, index),然后就可以直接返回結果。但是之前做過的一個項目打破了這樣的認知,在pytorch的dataloader中是會對通過getitem方法得到的結果(batch)進行包裝,而這個包裝可能與我們想要的有所不同。默認的方法可以看以下代碼:

def default_collate(batch):
    r"""Puts each data field into a tensor with outer dimension batch size"""

    elem_type = type(batch[0])
    if isinstance(batch[0], torch.Tensor):
        out = None
        if _use_shared_memory:
            # If we're in a background process, concatenate directly into a
            # shared memory tensor to avoid an extra copy
            numel = sum([x.numel() for x in batch])
            storage = batch[0].storage()._new_shared(numel)
            out = batch[0].new(storage)
        return torch.stack(batch, 0, out=out)
    elif elem_type.__module__ == 'numpy' and elem_type.__name__ != 'str_' \
            and elem_type.__name__ != 'string_':
        elem = batch[0]
        if elem_type.__name__ == 'ndarray':
            # array of string classes and object
            if np_str_obj_array_pattern.search(elem.dtype.str) is not None:
                raise TypeError(error_msg_fmt.format(elem.dtype))

            return default_collate([torch.from_numpy(b) for b in batch])
        if elem.shape == ():  # scalars
            py_type = float if elem.dtype.name.startswith('float') else int
            return numpy_type_map[elem.dtype.name](list(map(py_type, batch)))
    elif isinstance(batch[0], float):
        return torch.tensor(batch, dtype=torch.float64)
    elif isinstance(batch[0], int_classes):
        return torch.tensor(batch)
    elif isinstance(batch[0], string_classes):
        return batch
    elif isinstance(batch[0], container_abcs.Mapping):
        return {key: default_collate([d[key] for d in batch]) for key in batch[0]}
    elif isinstance(batch[0], tuple) and hasattr(batch[0], '_fields'):  # namedtuple
        return type(batch[0])(*(default_collate(samples) for samples in zip(*batch)))
    elif isinstance(batch[0], container_abcs.Sequence):
        transposed = zip(*batch)
        return [default_collate(samples) for samples in transposed]

    raise TypeError((error_msg_fmt.format(type(batch[0]))))

會根據你的數據類型進行相應的處理,但是這往往不是我們需要的,所以需要修改collate_fn,具體內容請看代碼,比較簡單,就不多贅述。

后記:今天的代碼讀的比較費力,僅僅通過數據加載這部分就能感受到作者所添加的trick,還有思維的嚴禁,對數據的限制,處理,都已經提前想好了。不僅如此,作者還添加了巨多的數據增強方法,不僅有傳統的仿射變換、上下翻轉、左右翻轉還有比較新穎的比如鑲嵌。以上就是為各位大致理了一遍思路,具體的實現還需要再進行細細的琢磨,不過就使用而言,以上信息就已經足夠。由于時間倉促,可能還有一些內容調查的不夠嚴謹,比如說鑲嵌這個翻譯是否正確,歡迎有這方面了解的大佬與我溝通,期待您的指教。


參考文獻

矩形訓練相關:https://blog.csdn.net/songwsx/article/details/102639770

仿射變換:https://zhuanlan.zhihu.com/p/93822508

Rectangle Trainning:https://github.com/ultralytics/yolov3/issues/232

數據自由讀取:https://zhuanlan.zhihu.com/p/30385675

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容