본문 바로가기

책/파이토치 트랜스포머를 활용한 자연어 처리와 컴퓨터비전 심층학습

09 객체 탐지 (2) Faster R-CNN pytorch 실습

MS COCO(Microsoft Common Objects in Context) 데이터셋으로 Faster R-CNN 모델을 미세 조정해본다. 

 

MS COCO 데이터셋은 Bounding Box 탐지, 객체 분할 및 캡션 생성을 위한 데이터를 제공한다. 이 데이터셋은 약 328,000장의 이미지와 약 2,500만 개의 레이블을 제공하고 80개의 클래스로 이뤄져있다.

 

여기서는 책에서 제공하는 개와 고양이 클래스로 소규모 샘플링된 데이터셋을 사용한다.

 

 

import os
import json
from pathlib import Path

from PIL import Image
from pycocotools.coco import COCO

import torch
from torch import optim
from torchvision import transforms, models, ops
from torchvision.models.detection import rpn, FasterRCNN
from torch.utils.data import Dataset, DataLoader

 

 

1. 어노테이션 정보 확인

ROOT_PATH = Path("/content/drive/MyDrive/blog/PyTorch_using_transformers")

DATA_PATH = "./DATA"
!unzip -qq {ROOT_PATH / 'archive.zip'} -d {DATA_PATH}

train_ano_path = DATA_PATH + '/datasets/coco/annotations/train_annotations.json'
with open(train_ano_path, 'r') as json_file:
  train_annotations = json.load(json_file)

for key in train_annotations.keys():
  print(key)
info
licenses
categories
images
annotations

 

annotation 파일은 JSON 형식으로 제공되며 json 모듈로 파이썬 딕셔너리 형태로 저장할 수 있다.

 

 

licenses을 들여다보면 url, license id, name이 있다.

train_annotations['licenses']
[{'url': 'http://creativecommons.org/licenses/by-nc-sa/2.0/',
  'id': 1,
  'name': 'Attribution-NonCommercial-ShareAlike License'},
 {'url': 'http://creativecommons.org/licenses/by-nc/2.0/',
  'id': 2,
  'name': 'Attribution-NonCommercial License'},
 {'url': 'http://creativecommons.org/licenses/by-nc-nd/2.0/',
  'id': 3,
  'name': 'Attribution-NonCommercial-NoDerivs License'},
 {'url': 'http://creativecommons.org/licenses/by/2.0/',
  'id': 4,
  'name': 'Attribution License'},
 {'url': 'http://creativecommons.org/licenses/by-sa/2.0/',
  'id': 5,
  'name': 'Attribution-ShareAlike License'},
 {'url': 'http://creativecommons.org/licenses/by-nd/2.0/',
  'id': 6,
  'name': 'Attribution-NoDerivs License'},
 {'url': 'http://flickr.com/commons/usage/',
  'id': 7,
  'name': 'No known copyright restrictions'},
 {'url': 'http://www.usa.gov/copyright.shtml',
  'id': 8,
  'name': 'United States Government Work'}]

 

 

categories를 보면 고양이는 category id가 1이고 개는 2이다. 나중에 데이터셋 클래스를 정의할 때 보겠지만 id 0으로 배경 클래스를 추가한다.

train_annotations['categories']
[{'supercategory': 'animal', 'id': 1, 'name': 'cat'},
 {'supercategory': 'animal', 'id': 2, 'name': 'dog'}]

 

 

학습 데이터의 이미지 개수는 3000개이다. 이미지에 license가 1이라는 것은 license id가 1이라는것을 의미한다. 여기서는 file_nameimage의 id가 존재한다는 것만 기억하면 된다

print(len(train_annotations['images']))
train_annotations['images'][0]
3000
{'license': 1,
 'file_name': '000000495357.jpg',
 'coco_url': 'http://images.cocodataset.org/train2017/000000495357.jpg',
 'height': 479,
 'width': 640,
 'date_captured': '2013-11-15 12:47:11',
 'flickr_url': 'http://farm5.staticflickr.com/4053/4432398298_d729bfc4e3_z.jpg',
 'id': 495357}

 

 

  • segmentation은 $x_1, y_1, ...x_n, y_n$의 구조로 담겨 있다.
  • iscrowd는 군집 객체 여부를 의미하며, 1은 픽셀 수준으로 분리가 어려운 경우에 할당된다. 우리가 사용하는 소규모로 샘플링된 데이터셋은 전부 0으로 구성돼 있다.
  • annotations에는 image_id, category_id, 그리고 annotation id가 존재한다. 뒤에서 데이터셋 클래스를 정의할 때 annotation id로 접근하고 image_id로 다시 접근하여 이미지를 가져오는 방식을 취한다.
  • bbox는 좌상단 x, 좌상단 y, h, w 구조이다.
train_annotations['annotations'][0]
{'segmentation': [[374.46,
   310.42,
   386.68,
   310.17,
   394.32,
   ...
   373.51,
   311.21,
   376.35,
   310.86]],
 'area': 2243.7513000000004,
 'iscrowd': 0,
 'image_id': 495357,
 'bbox': [337.02, 244.46, 66.47, 66.75],
 'category_id': 2,
 'id': 1727}

 

 

2. 데이터셋

pycocotools 라이브러리를 활용해 이미지와 어노테이션 정보를 읽어온다. colab에서는 pycocotools가 기본으로 설치되어있다.

 

일단 COCO API를 조금만 살펴보자.

annotations = os.path.join(DATA_PATH, "annotations", f"train_annotations.json")
coco = COCO(annotations)
dir(coco)
['__class__',
 '__delattr__',
 '__dict__',
 '__dir__',
 '__doc__',
 '__eq__',
 '__format__',
 '__ge__',
 '__getattribute__',
 '__gt__',
 '__hash__',
 '__init__',
 '__init_subclass__',
 '__le__',
 '__lt__',
 '__module__',
 '__ne__',
 '__new__',
 '__reduce__',
 '__reduce_ex__',
 '__repr__',
 '__setattr__',
 '__sizeof__',
 '__str__',
 '__subclasshook__',
 '__weakref__',
 'annToMask',
 'annToRLE',
 'anns',
 'catToImgs',
 'cats',
 'createIndex',
 'dataset',
 'download',
 'getAnnIds',
 'getCatIds',
 'getImgIds',
 'imgToAnns',
 'imgs',
 'info',
 'loadAnns',
 'loadCats',
 'loadImgs',
 'loadNumpyAnnotations',
 'loadRes',
 'showAnns']

 

여러 메서드와 속성이 있는데 우리는 여기서 cats, imgs, loadImgs, getAnnIds, loadAnns만 알면된다.

 

  • cats 속성은 annotation의 category 정보를 딕셔너리 형태로 반환한다.
coco.cats
{1: {'supercategory': 'animal', 'id': 1, 'name': 'cat'},
 2: {'supercategory': 'animal', 'id': 2, 'name': 'dog'}}

 

  • imgs 속성은 image id를 key로하고 위에서 봤던 이미지 정보를 value로 하는 딕셔너리 형태이다.
  • loadImgs(image_id)는 image id를 입력받아 딕셔너리 형태의 이미지 정보가 저장되어 있는 리스트를 반환한다. 
coco.loadImgs(16164)
[{'license': 3,
  'file_name': '000000016164.jpg',
  'coco_url': 'http://images.cocodataset.org/train2017/000000016164.jpg',
  'height': 486,
  'width': 640,
  'date_captured': '2013-11-15 01:17:31',
  'flickr_url': 'http://farm4.staticflickr.com/3634/3585567454_cce4ce9f4f_z.jpg',
  'id': 16164}]

 

  • getAnnIds(image_id)는 image id를 입력받아 매핑되는 annotation id를 리스트 형태로 반환한다.
coco.getAnnIds(16164)
[1767]

 

  • loadAnns(annotation_id)는 annotation id를 입력받아 딕셔너리 형태의 annotation 정보가 담겨있는 리스트를 반환한다.

여기까지만 알아보고 데이터셋 클래스를 구축해보자.

 

 

데이터셋 클래스 선언

class COCODataset(Dataset):
  def __init__(self, root, train, transform=None):
    super().__init__()

    directory = "train" if train else "val"
    annotation_path = os.path.join(root, "annotations", f"{directory}_annotations.json")

    self.coco = COCO(annotation_path)
    self.image_path = os.path.join(root, directory)
    self.transform = transform

    self.categories = self._get_categories()
    self.data = self._load_data()

  def _get_categories(self):
    categories = {0:"background"}
    for category in self.coco.cats.values():
      categories[category['id']] = category["name"]
    return categories

 

 

COCO 데이터셋 불러오기

def _load_data(self):
  data = []
  for _id in self.coco.imgs:
    file_name = self.coco.loadImgs(_id)[0]["file_name"]
    image_path = os.path.join(self.image_path, file_name)
    image = Image.open(image_path).convert("RGB")

    boxes = []
    labels = []
    anns = self.coco.loadAnns(self.coco.getAnnIds(_id))
    x, y, w, h = anns[0]["bbox"]
    boxes.append([x, y, x + w, y + h]) # Faster R-CNN은 (x_min, y_min, x_max, y_max) 구조의 bbox를 사용
    labels.append(anns[0]["category_id"])

    target = {
        "image_id":torch.LongTensor([_id]),
        "boxes":torch.FloatTensor(boxes),
        "labels":torch.LongTensor(labels),
    }
    data.append([image, target])
  return data

COCODataset._load_data = _load_data

 

 

호출 및 길이 반환 메서드

def __getitem__(self, index):
  image, target = self.data[index]
  if self.transform:
    image = self.transform(image)
  return image, target

def __len__(self):
  return len(self.data)

COCODataset.__getitem__ = __getitem__
COCODataset.__len__ = __len__

 

 

데이터로더

  • COCO 데이터셋은 이미지 내에 여러 객체 정보가 담길 수 있으므로 데이터의 길이가 다를 수 있다. 그러므로 데이터로더에 아래와 같은 collator 함수를 적용해 데이터를 패딩한다.
  • PILToTensor()를 적용하면 자동으로 torch.float32 형태로 변환된다. 그럼에도 불구하고 ConvertImageDtype을 적용해주는 이유는 데이터 타입을 명시하기 위함이다.
def collator(batch):
  return tuple(zip(*batch))

transform = transforms.Compose(
    [
        transforms.PILToTensor(),
        transforms.ConvertImageDtype(torch.float),
    ]
)

train_dataset = COCODataset(os.path.join(DATA_PATH, "datasets", "coco"), train=True, transform=transform)
val_dataset = COCODataset(os.path.join(DATA_PATH, "datasets", "coco"), train=False, transform=transform)

train_dataloader = DataLoader(train_dataset, batch_size=4, shuffle=True, collate_fn=collator)
val_dataloader = DataLoader(val_dataset, batch_size=1, shuffle=False, collate_fn=collator)

 

 

3. 모델 준비

  • backbone으로 이미지넷으로 사전학습된 VGG-16을 사용한다. 이때 특징 맵만 추출하면 되므로 avgpool과 classifier를 제외한 features만 가져다가 쓴다.
  • FasterRCNN에 사용할 backbone 모델은 out_channels 속성을 포함해야하기 때문에 이를 512로 할당해준다.
  • rpn의 AnchorGenerator 클래스는 입력 이미지의 각 픽셀에 대해 앵커 박스를 생성한다. 앵커 박스에 사용되는 매개변수 형식은 Tuple[Tuple[int]] 구조를 가져야 한다. 그러므로 콤마를 포함해 튜플 구조로 지정한다.
  • ops의 MultiScaleRoIAlign 클래스는 RoI 정렬 기능이 포함된 클래스로 다중 스케일 이미지에서 RoI Pooling을 수행한다.
  • featmap_names는 RoI Pooling 에 사용할 특징 맵의 이름을 설정한다. VGG-16 모델의 특징 추출 계층은 "0"으로 정의돼 있다.
  • output_size는 RoI Pooling을 통해 추출된 특징 맵의 크기를 (height, width) 형태로 지정한다.
  • sampling_ratio는 RoI 특징 맵 사용 시 원본 특징 맵 영역을 샘플링하는데 사용되는 그리드의 크기를 지정한다.
backbone = models.vgg16(weights="VGG16_Weights.IMAGENET1K_V1").features
backbone.out_channels = 512

anchor_generator = rpn.AnchorGenerator(
    sizes=((32, 64, 128, 256, 512),),
    aspect_ratios=((0.5, 1.0, 2.0),)
)

roi_pooler = ops.MultiScaleRoIAlign(
    featmap_names=["0"],
    output_size=(7, 7),
    sampling_ratio=2,
)

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = FasterRCNN(
    backbone=backbone,
    num_classes=3,
    rpn_anchor_generator=anchor_generator,
    box_roi_pool=roi_pooler,
).to(device)

 

 

최적화 및 학습률 스케줄러 

params = [p for p in model.parameters() if p.requires_grad]
optimizer = optim.SGD(params, lr=1e-3, momentum=0.9, weight_decay=5e-4)
lr_scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=5, gamma=0.1)

 

 

4. 모델 학습

 

Fine tuning

  • FasterRCNN이 반환하는 loss_dict는 loss_classifier(분류 손실), loss_box_reg(박스 회귀 손실), loss_objectness(객체 유무 손실), loss_rpn_box_reg(RPN 손실) 정보가 딕셔너리 구조로 생성된다.
from tqdm import tqdm

for epoch in range(5):
  cost = 0.0
  for idx, (images, targets) in tqdm(enumerate(train_dataloader), total=len(train_dataloader), desc=f'Epoch {epoch + 1}'):
    images = list(image.to(device) for image in images)
    targets = [{k: v.to(device) for k, v in t.items()} for t in targets]

    loss_dict = model(images, targets)
    losses = sum(loss for loss in loss_dict.values())

    optimizer.zero_grad()
    losses.backward()
    optimizer.step()

    cost += losses

  lr_scheduler.step()
  cost = cost / len(train_dataloader)
  print(f"epoch: {epoch+1:4d}, cost: {cost:.3f}")
Epoch 1: 100%|██████████| 608/608 [18:26<00:00,  1.82s/it]
epoch:    1, cost: 0.413
Epoch 2: 100%|██████████| 608/608 [18:55<00:00,  1.87s/it]
epoch:    2, cost: 0.273
Epoch 3: 100%|██████████| 608/608 [19:01<00:00,  1.88s/it]
epoch:    3, cost: 0.255
Epoch 4: 100%|██████████| 608/608 [19:03<00:00,  1.88s/it]
epoch:    4, cost: 0.237
Epoch 5: 100%|██████████| 608/608 [18:50<00:00,  1.86s/it]
epoch:    5, cost: 0.230

 

3000개의 데이터를 5 에포크 학습하는데 1시간 30분 정도 소요됐다.

 

 

모델 추론 및 시각화

import numpy as np
from matplotlib import pyplot as plt
from torchvision.transforms.functional import to_pil_image

def draw_bbox(ax, box, text, color):
  ax.add_patch(
      plt.Rectangle(
          xy=box[:2],
          width=box[2] - box[0],
          height=box[3] - box[1],
          fill=False,
          edgecolor=color,
          linewidth=2,
      )
  )
  ax.annotate(
      text=text,
      xy=(box[0] - 5, box[1] - 5),
      color=color,
      weight="bold",
      fontsize=13,
  )

threshold = 0.5
categories = val_dataset.categories
with torch.no_grad():
  model.eval()

  fig = plt.figure(figsize=(12, 12))

  for idx, (images, targets) in enumerate(val_dataloader):
    images = [image.to(device) for image in images]
    outputs = model(images)

    boxes = outputs[0]["boxes"].detach().cpu().numpy()
    scores = outputs[0]["scores"].detach().cpu().numpy()
    labels = outputs[0]["labels"].detach().cpu().numpy()

    boxes = boxes[scores >= threshold].astype(np.int32)
    labels = labels[scores >= threshold]
    scores = scores[scores >= threshold]

    ax = fig.add_subplot(2, 2, idx + 1)
    ax.imshow(to_pil_image(images[0]))

    for box, score, label in zip(boxes, scores, labels):
      draw_bbox(ax, box, f"{categories[label]}: {score:.4f}", "red")

    tboxes = targets[0]["boxes"].numpy()
    tlabels = targets[0]["labels"].numpy()

    for tbox, tlabel in zip(tboxes, tlabels):
      draw_bbox(ax, tbox, f"{categories[tlabel]}", "green")

    if idx == 3:
      break
  plt.show()

 

객체가 작은 경우에 탐지하지 못하거나 개의 일부분만 있는 경우 탐지하지 못한다. 물론 실습을 위해 적은 데이터로 5 에포크만 학습해서 성능이 낮은 것은 당연하다.

 

참고로 모델의 output은 다음과 같다.

sample = list(iter(val_dataloader))[1]
images, targets = sample
images = [image.to(device) for image in images]
outputs = model(images) 

outputs
[{'boxes': tensor([[255.6647, 186.2069, 435.0408, 480.0000],
          [282.1464, 177.7444, 426.9679, 480.0000],
          [175.4605, 164.5507, 513.6504, 475.9303],
          [195.0678, 293.4507, 453.4210, 453.4911],
          [214.4374, 147.3351, 622.1478, 470.6471]], device='cuda:0',
         grad_fn=<StackBackward0>),
  'labels': tensor([2, 1, 2, 2, 1], device='cuda:0'),
  'scores': tensor([0.7221, 0.1806, 0.1128, 0.0940, 0.0616], device='cuda:0',
         grad_fn=<IndexBackward0>)}]

 

 

5. 모델 평가

  • COCOeval을 사용해서 평가를 수행할 수 있다.
  • prediction 데이터는 [image id, x, y, w, h, score, label] 형태로 전달해야한다.
  • coco_gt는 ground truth를 의미
  • coco_dt는 coco_gt를 사용해 loadRes 속성으로 넘파이 어레이를 COCO API 형식으로 바꿔준다.
  • 그런 다음 COCOeval 인스턴스를 형성한다. evaluate은 precision과 recall을 계산하며 accumulate로 계산된 precision, recall을 누적한다. 마지막으로 summarize 메서드로 결과를 출력한다.
from pycocotools.cocoeval import COCOeval

with torch.no_grad():
  model.eval()
  coco_detections = []
  for images, targets in val_dataloader:
    images = [image.to(device) for image in images]
    outputs = model(images)

    for i in range(len(targets)):
      image_id = targets[i]["image_id"].data.cpu().numpy().tolist()[0]
      boxes = outputs[i]["boxes"].data.cpu().numpy()
      boxes[:, 2] = boxes[:, 2] - boxes[:, 0] # x_max 값은 width로 변환
      boxes[:, 3] = boxes[:, 3] - boxes[:, 1] # y_max 값은 height로 변환
      scores = outputs[i]["scores"].data.cpu().numpy()
      labels = outputs[i]["labels"].data.cpu().numpy()

      for instance_id in range(len(boxes)):
        box = boxes[instance_id, :].tolist()
        prediction = np.array(
            [
                image_id,
                box[0],
                box[1],
                box[2],
                box[3],
                float(scores[instance_id]),
                int(labels[instance_id]),
            ]
        )
        coco_detections.append(prediction)

  coco_detections = np.asarray(coco_detections)
  coco_gt = val_dataloader.dataset.coco # Ground Truth
  coco_dt = coco_gt.loadRes(coco_detections) # Detection
  coco_eval = COCOeval(coco_gt, coco_dt, "bbox")
  coco_eval.evaluate()
  coco_eval.accumulate()
  coco_eval.summarize()
 Average Precision  (AP) @[ IoU=0.50:0.95 | area=   all | maxDets=100 ] = 0.262
 Average Precision  (AP) @[ IoU=0.50      | area=   all | maxDets=100 ] = 0.592
 Average Precision  (AP) @[ IoU=0.75      | area=   all | maxDets=100 ] = 0.155
 Average Precision  (AP) @[ IoU=0.50:0.95 | area= small | maxDets=100 ] = 0.005
 Average Precision  (AP) @[ IoU=0.50:0.95 | area=medium | maxDets=100 ] = 0.264
 Average Precision  (AP) @[ IoU=0.50:0.95 | area= large | maxDets=100 ] = 0.290
 Average Recall     (AR) @[ IoU=0.50:0.95 | area=   all | maxDets=  1 ] = 0.323
 Average Recall     (AR) @[ IoU=0.50:0.95 | area=   all | maxDets= 10 ] = 0.427
 Average Recall     (AR) @[ IoU=0.50:0.95 | area=   all | maxDets=100 ] = 0.427
 Average Recall     (AR) @[ IoU=0.50:0.95 | area= small | maxDets=100 ] = 0.025
 Average Recall     (AR) @[ IoU=0.50:0.95 | area=medium | maxDets=100 ] = 0.347
 Average Recall     (AR) @[ IoU=0.50:0.95 | area= large | maxDets=100 ] = 0.491

 

  • area는 객체의 크기를 나타낸다. all은 전체 크기를 의미하고, small은 작은 크기의 객체에 대한 평가만 수행된다. 4번 째 줄을 보면 아까 위에서 작은 크기의 객체를 탐지하지 못한 결과를 봤었는데 이 값이 0.005로 매우 낮다.
  • maxDets는 최대로 허용되는 탐지수를 의미한다.
  • 일반적으로 IoU=0.50:0.95인 mAP를 척도로 사용하는데, 이 값이 정밀도의 경우 0.262로 모델의 성능은 낮다고 평가할 수 있다.