【庖丁解牛】從零實現RetinaNet(二):RetinaNet中的resize、數據預讀取、數據增強、collater處理

所有代碼已上傳到本人github repository:https://github.com/zgcr/pytorch-ImageNet-CIFAR-COCO-VOC-training
如果覺得有用,請點個star喲!
下列代碼均在pytorch1.4版本中測試過,確認正確無誤。

RetinaNet中的resize

在以往的分類任務中,對於圖片resize我們不會保持其長寬比,而是長和寬直接resize到指定尺寸。在RetinaNet和其他大多數目標檢測網絡的訓練中,resize必須要保持圖片原來的長寬比。
RetinaNet的resize遵循以下原則:

  • 首先確定兩個resize後短長邊的閾值,短邊爲800,長邊爲1333。
  • 然後用800除以原始短邊長度得到scale,然後用scale乘以原始長邊長度得到縮放後長邊長度。
  • 檢測縮放後長邊的長度,如果縮放到長邊長度不超過1333,則就用這個scale乘以原始長短邊程度進行resize;如果縮放後長邊的長度超過1333,則用1333除以原始長邊長度得到新的scale,用新的scale乘以原始長短邊長度進行resize。

800和1333的這兩個閾值是固定值,如果我們想resize到其他尺寸,比如600,那麼長邊就不能超過600除以800乘以1333,即1000。在RetinaNet論文中給出了各個resize尺寸下的模型點數,這個resize尺寸實際上指的就是上面resize後短邊的閾值。

各個resize尺寸下短長邊的閾值:

min_length=400,max_length=667
min_length=500,max_length=833
min_length=600,max_length=1000
min_length=700,max_length=1166
min_length=800,max_length=1333

對於一個batch的圖片,resize後可能會有兩種情況:短邊爲800,長邊小於等於1333;短邊小於等於800,長邊爲1333。在進行訓練時,我們必須要保證每個batch中圖片的shape完全一致。注意每張圖片的短邊可能都不一樣,有的可能是圖片的寬,有的可能是圖片的寬。因此,我們將所有圖片都填充到1333x1333也就是resize後長邊的尺寸,空白的地方全部用0值填充。

在github上的許多RetinaNet實現中,並沒有採用上面的標準resize做法。而是直接用resize後尺寸除以長邊來求得scale,這樣長邊resize後始終等於resize後尺寸。我對這兩種方法進行了實驗,發現直接用resize後尺寸除以長邊求得scale的resize方法訓練出來的模型效果更好。爲了探究原因,我計算了這兩種resize方法resize後的圖片尺寸和圖片面積,並把整個COCO數據集的圖片面積求平均值,結果如下:

# retinanet resize method即上面一開始提到的RetinaNet標準Resize方法,my resize method即直接用resize後尺寸除以長邊求得scale然後resize的方法。

# retinanet resize method,resize爲短邊閾值,per_image_average_area爲圖片resize後的平均面積,input shape爲填充0值使所有圖片長寬相等後輸入網絡時的尺寸
# resize=400,per_image_average_area=223743,input shape=[667,667]
# resize=500,per_image_average_area=347964,input shape=[833,833]
# resize=600,per_image_average_area=502820,input shape=[1000,1000]
# resize=700,per_image_average_area=682333,input shape=[1166,1166]
# resize=800,per_image_average_area=891169,input shape=[1333,1333]

# my resize method,resize爲reisze後尺寸,per_image_average_area爲圖片resize後的平均面積,input shape爲填充0值使所有圖片長寬相等後輸入網絡時的尺寸
# resize=600,per_image_average_area=258182,input shape=[600,600]
# resize=667,per_image_average_area=318986,input shape=[667,667]
# resize=700,per_image_average_area=351427,input shape=[700,700]
# resize=800,per_image_average_area=459021,input shape=[800,800]
# resize=833,per_image_average_area=497426,input shape=[833,833]
# resize=900,per_image_average_area=580988,input shape=[900,900]
# resize=1000,per_image_average_area=717349,input shape=[1000,1000]
# resize=1166,per_image_average_area=974939,input shape=[1166,1166]
# resize=1333,per_image_average_area=1274284,input shape=[1333,1333]

通過上面的計算可以發現,如果以最終輸入網絡的input shape爲基準,相同input shape下後一種resize方法得到的圖片平均面積更大,換句話來說,就是resize後圖片的清晰度更高。顯然,網絡學習的圖片清晰度越高,則最終模型表現效果越好,這也與我前面的實驗結論吻合。另外,相同圖片清晰度下後一種resize方法需要的input shape更小,那麼網絡的前向計算flops就會更小,佔用顯存也會變少,在同一張顯卡上進行訓練時batchsize可以調整的更大,訓練速度也更快,這對訓練和推理都非常有利。因此,在後面的實驗中,我使用後一種resize方法。

使用後一種resize方法後,如何與RetinaNet論文中報告的點數進行對點?
由於我只有2張2080ti顯卡,在使用後一種resize方法時,resize=600時剛好一張2080ti的batchsize可以調到8,兩張2080ti顯卡的總batchsize爲16,這個batchsize也是原論文中訓練時採用的batchsize,方便對點。由於目標檢測中使用apex自動混合精度訓練時有時會使得loss變nan,因此在後面的實驗中我們不使用apex。
在上面的計算結果中,第一種resize方法分辨率爲400和500是圖片的平均面積是223743和347964,兩者尺寸差100,面積差124221。而後一種resize方法分辨率爲600時圖片的平均面積是258182,比第一種resize方法分辨率爲400的圖片平均面積大34439,是面積差124221的0.277倍。我們假設所有圖片平均的寬高比是1比1,把0.277開根號再乘以100加上400,則可以得到後一種resize方法在與第一種resize方法保持圖片平均面積一致的情況下的估算分辨率爲450。在RetinaNet論文中,分辨率400和500報告的mAP分別爲30.5和32.5,我們假設mAP會隨着面積增大而線性增大。那麼分辨率450時的估計mAP爲2乘以0.277再加上30.5,大概31.1左右。後面的復現我會將模型表現與這個31.1相比,如果達到31.1,說明覆現的模型達到了論文中報告的性能。

數據預讀取

正常情況下,pytorch訓練時先加載本次batch的數據,然後再進行本次batch的前向計算,最後反向傳播。所謂數據與讀取就是模型在進行本次batch的前向計算和反向傳播時就預先加載下一個batch的數據,這樣就節省了下加載數據的時間(相當於加載數據與前向計算和反向傳播並行了)。
數據預讀取代碼如下:

class COCODataPrefetcher():
    def __init__(self, loader):
        self.loader = iter(loader)
        self.stream = torch.cuda.Stream()
        self.preload()

    def preload(self):
        try:
            sample = next(self.loader)
            self.next_input, self.next_annot = sample['img'], sample['annot']
        except StopIteration:
            self.next_input = None
            self.next_annot = None
            return
        with torch.cuda.stream(self.stream):
            self.next_input = self.next_input.cuda(non_blocking=True)
            self.next_annot = self.next_annot.cuda(non_blocking=True)
            self.next_input = self.next_input.float()

    def next(self):
        torch.cuda.current_stream().wait_stream(self.stream)
        input = self.next_input
        annot = self.next_annot
        self.preload()
        return input, annot

下面提供了一個訓練中使用數據預讀取類的例子:

def train(train_loader, model, criterion, optimizer, scheduler, epoch, logger,
          args):
    cls_losses, reg_losses, losses = [], [], []

    # switch to train mode
    model.train()

    iters = len(train_loader.dataset) // args.batch_size
    prefetcher = COCODataPrefetcher(train_loader)
    images, annotations = prefetcher.next()
    iter_index = 1

    while images is not None:
        images, annotations = images.cuda().float(), annotations.cuda()
        cls_heads, reg_heads, batch_anchors = model(images)
        cls_loss, reg_loss = criterion(cls_heads, reg_heads, batch_anchors,
                                       annotations)
        loss = cls_loss + reg_loss
        if cls_loss == 0.0 or reg_loss == 0.0:
            optimizer.zero_grad()
            continue

        if args.apex:
            with amp.scale_loss(loss, optimizer) as scaled_loss:
                scaled_loss.backward()
        else:
            loss.backward()

        torch.nn.utils.clip_grad_norm_(model.parameters(), 0.1)
        optimizer.step()
        optimizer.zero_grad()

        cls_losses.append(cls_loss.item())
        reg_losses.append(reg_loss.item())
        losses.append(loss.item())

        images, annotations = prefetcher.next()

        if iter_index % args.print_interval == 0:
            logger.info(
                f"train: epoch {epoch:0>3d}, iter [{iter_index:0>5d}, {iters:0>5d}], cls_loss: {cls_loss.item():.2f}, reg_loss: {reg_loss.item():.2f}, loss_total: {loss.item():.2f}"
            )

        iter_index += 1

    scheduler.step(np.mean(losses))

    return np.mean(cls_losses), np.mean(reg_losses), np.mean(losses)

train函數代表一個epoch內的訓練過程。

數據增強

在分類任務中,我們直接調用torchvision.transform中的各個數據增強函數即可實現數據增強。在目標檢測任務中,由於數據增強後圖片上目標的位置可能發生變化,因此我們必須自己定義數據增強函數同時處理圖片和目標的座標。對於RetinaNet,訓練只需要做randomflip數據增強,然後resize即可。測試時則直接resize。除此之外,我還實現了RandomCrop和RandomTranslate數據增強。
數據增強代碼如下:

class RandomFlip(object):
    def __init__(self, flip_prob=0.5):
        self.flip_prob = flip_prob

    def __call__(self, sample):
        if np.random.uniform(0, 1) < self.flip_prob:
            image, annots, scale = sample['img'], sample['annot'], sample[
                'scale']
            image = image[:, ::-1, :]

            _, width, _ = image.shape

            x1 = annots[:, 0].copy()
            x2 = annots[:, 2].copy()

            annots[:, 0] = width - x2
            annots[:, 2] = width - x1

            sample = {'img': image, 'annot': annots, 'scale': scale}

        return sample


class RandomCrop(object):
    def __init__(self, crop_prob=0.5):
        self.crop_prob = crop_prob

    def __call__(self, sample):
        image, annots, scale = sample['img'], sample['annot'], sample['scale']

        if annots.shape[0] == 0:
            return sample

        if np.random.uniform(0, 1) < self.crop_prob:
            h, w, _ = image.shape
            max_bbox = np.concatenate([
                np.min(annots[:, 0:2], axis=0),
                np.max(annots[:, 2:4], axis=0)
            ],
                                      axis=-1)
            max_left_trans, max_up_trans = max_bbox[0], max_bbox[1]
            max_right_trans, max_down_trans = w - max_bbox[2], h - max_bbox[3]
            crop_xmin = max(
                0, int(max_bbox[0] - random.uniform(0, max_left_trans)))
            crop_ymin = max(0,
                            int(max_bbox[1] - random.uniform(0, max_up_trans)))
            crop_xmax = max(
                w, int(max_bbox[2] + random.uniform(0, max_right_trans)))
            crop_ymax = max(
                h, int(max_bbox[3] + random.uniform(0, max_down_trans)))

            image = image[crop_ymin:crop_ymax, crop_xmin:crop_xmax]
            annots[:, [0, 2]] = annots[:, [0, 2]] - crop_xmin
            annots[:, [1, 3]] = annots[:, [1, 3]] - crop_ymin

            sample = {'img': image, 'annot': annots, 'scale': scale}

        return sample


class RandomTranslate(object):
    def __init__(self, translate_prob=0.5):
        self.translate_prob = translate_prob

    def __call__(self, sample):
        image, annots, scale = sample['img'], sample['annot'], sample['scale']

        if annots.shape[0] == 0:
            return sample

        if np.random.uniform(0, 1) < self.translate_prob:
            h, w, _ = image.shape
            max_bbox = np.concatenate([
                np.min(annots[:, 0:2], axis=0),
                np.max(annots[:, 2:4], axis=0)
            ],
                                      axis=-1)
            max_left_trans, max_up_trans = max_bbox[0], max_bbox[1]
            max_right_trans, max_down_trans = w - max_bbox[2], h - max_bbox[3]
            tx = random.uniform(-(max_left_trans - 1), (max_right_trans - 1))
            ty = random.uniform(-(max_up_trans - 1), (max_down_trans - 1))
            M = np.array([[1, 0, tx], [0, 1, ty]])
            image = cv2.warpAffine(image, M, (w, h))
            annots[:, [0, 2]] = annots[:, [0, 2]] + tx
            annots[:, [1, 3]] = annots[:, [1, 3]] + ty

            sample = {'img': image, 'annot': annots, 'scale': scale}

        return sample

collater處理

對於一個batch的images和annotations,我們最後還需要用collater函數將images和annotations的shape全部對齊後才能輸入模型進行訓練。
collater函數代碼如下:

def collater(data):
    imgs = [s['img'] for s in data]
    annots = [s['annot'] for s in data]
    scales = [s['scale'] for s in data]

    imgs = torch.from_numpy(np.stack(imgs, axis=0))

    max_num_annots = max(annot.shape[0] for annot in annots)

    if max_num_annots > 0:

        annot_padded = torch.ones((len(annots), max_num_annots, 5)) * (-1)

        if max_num_annots > 0:
            for idx, annot in enumerate(annots):
                if annot.shape[0] > 0:
                    annot_padded[idx, :annot.shape[0], :] = annot
    else:
        annot_padded = torch.ones((len(annots), 1, 5)) * (-1)

    imgs = imgs.permute(0, 3, 1, 2)

    return {'img': imgs, 'annot': annot_padded, 'scale': scales}

對於images,由於我們前面的Resize類已經將其shape對齊了,所以這裏不再做處理。對於annotations,由於每張圖片標註的object數量都不一樣,還有可能出現某張圖上沒有標註object的情況。我們取一個batch中所有圖片裏單張圖片中標註object數量的最大值,然後用值-1填充其他圖片的annotations,使得所有圖片的annotations中object數量都等於這個最大值。在進行訓練時,我們會在loss部分處理掉這部分值-1的annotations。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章