본문 바로가기

Programmer Jinyo/Machine Learning

Yolo 논문 정리 및 Pytorch 코드 구현, 분석 01 ( You Only Look Once: Unified, Real-Time Object Detection )


투명한 기부를 하고싶다면 이 링크로 와보세요! 🥰 (클릭!)

바이낸스(₿) 수수료 평생 20% 할인받는 링크로 가입하기! 🔥 (클릭!)

Yolo 논문을 공부해야 할 일이 생겨서, 공부 하는 김에 내용과 코드 정리까지 하자는 성실한 마음으로 이 글을 쓰기 시작하기로 했다.


* 유감스럽게도 이 글은 아직 완성되지 못했습니다...


배경


Object Detection이란 머신러닝에서 한 사진 안에 어떤 물체들이 등장하는지에 대한 것을 판별해주는 Task를 말한다.


그리고 Yolo는 Object Detection을 보다 효율적으로 수행하는 방법을 소개한다.


이 글은 전반적인 YOLO 논문에서 다루는 내용을 그대로 번역(....) 수준으로 적어낼 것이므로 다른 정리된 글들보다는 다 읽은 후 명확한 이해를 할 수 있게 되길 기대한다.



Yolo는 기존의 방법들과 달리 Object detection을 이미지 픽셀 좌표에 대응되는 bounding box을 찾고 그것에 대한 class확률을 구하는 Single Regression Problem으로 바라보기로 하였다.


이 말이 이해가 잘 안가더라도 이후의 글을 읽으면 차차 이해가 될 것이다.


전체적인 대강의 흐름은 다음과 같다.



하나의 convolutional network 가 동시에 여러개의 bounding boxes를 예측하고, 각 bounding box에 대해 class probabilities 를 예측한다.



Unified Detection


네트워크의 큰 특징 중 하나는 이미지 전부로부터 features를 뽑아서 각 bounding box를 예측한다는 것이다.


시스템은 input image를 S x S grid로 나눈다.


그리고 만약 어떤 object의 한 가운데가 grid cell에 놓인다면, grid cell은 그 object를 탐지할 의무가 생긴다는 설정이다.


각 grid cell은 B개의 bounding boxes를 예측하고 그 box의 예측 대한 confidence score(자신감 점수..?)를 같이 예측한다.


confidence score은 얼마나 박스 안에 실제로 object가 존재하는지, 그리고 그 class를 얼마나 잘 반영했는지에 대한 것이다.


Confidence 는 형식적으로 Pr(Object) * IOU(intersection over union) 라고 정의한다.


( IOU란 정답 box와 예측 box의 교집합 크기 / 합집합 크기 이다 )


Object가 box안에 아무것도 없을 때에는 confidence가 0이 되어야 한다.

또, predicted box와 ground truth의 IOU가 얼마나 일치하는지가 confidence가 되도록 맞추고 싶다.


각 Bounding box는 5가지 예측을 한다.  x, y, w, h, confidence 이다.


(x,y)는 grid cell의 영역에 관련된 box의 center를 의미한다.

width, height (w,h)는 박스의 높이와 너비로서, 이미지 전부와 비례하게 예측된다.

마지막으로 confidence는 ground truth box와 예측된 box의 IOU를 예측한다.


각 grid cell은 C개의 (class 종류 개수) 조건부 확률 Pr(Class_i | Object)를 예측한다.

* grid cell당 B개의 bounding box를 예측하는 것과 상관 없이, cell당 한개의 class를 예측한다.


그 모든것을 곱해서 마지막에 예측을 한다.


( 왜 왼쪽 식이 오른쪽으로 전개가 되지..? Object와의 교집합이어야 하는거 아닌가.. )


여튼 이 식은 얼마나 box를 잘 예측했는지, 그리고 object의 class를 잘 맞추었는지를 나타내는 score가 된다.


PASCAL VOC에서 (데이터Set이다) YOLO를 평가하기 위해서 S = 7 , B = 2로 설정하였다. PASCAL VOC 는 20개의 labelled classs를 가지고 있으므로 C=20이 된다.


결국 최종적인 예측은 7 * 7 * 30 tensor이 될 것이다.


7*7인 grid에서 cell마다

20개의 class / bounding box마다 x y w h confidence 를 예측하므로

20 + 2 * 5  =  30을 통해 말이다.



Architecture Design




전반적인 구조는 왼쪽과 같이 Conv Layer들을 이어붙여서 만들었고, 


오른쪽의 화살표가 Cross된 부분은 Linear Layer을 표현한 것으로 7 * 7 * 1024의 상태에서 4096으로 Linear layer을 거쳤다.


그리고 마지막으로 7*7*30의 Output Linear Layer로 출력 해 주면 끝난다.


마지막 출력을 조금 더 자세히 보자면



이렇게 볼 수 있을 것이다. (objectness score가 confidence값이다)



Output processing


13*13grid의 경우를 생각 해 보면, 13*13*B개의 bounding box들이 나올 텐데, 어떻게 출력 결과물은 단 하나만 나오는 것일까?


두가지의 트릭을 사용한다.


1. Thresholding by Object Confidence Score


말 그대로 object box에서 confidence를 threshold아래로 예측한 box는 무시하는 방법이다.


2. Non-maximum Suppression


한 object를 판단했다고 여겨지는 중복되는 것들은 제거하는 방법인데,. 자세한 방법은 아래 링크를 참고하면 좋을 것 같다.


https://dyndy.tistory.com/275




Training


앞 쪽의 Conv Layers는 ImageNet 1000-class competition dataset으로부터 pretrain시켰다.


주로 다른 Pytorch등의 모델들을 보면 앞쪽 레이어는 Model Zoo등에서 가져온다.



Data는 PASCAL VOC 2007 데이터 셋을 썼다고 한다.





Code


https://blog.paperspace.com/tag/series-yolo/


이하 포스트는 위 링크를 참고하여 진행한다.


YOLO3가 나온 시점에서, 굳이 YOLO 기본을 코딩할 이유가 없어서 yolo3을 pytorch로 구현하는 코드를 슥 쇽 샥 들고왔다.


사실 다 작성하고 나니, 이것이 Yolo code 작성법인지 PyTorch Advanced practice example인지 모르겠다.



전반적인 코드를 들어가기에 앞서, 심리적인 거부감을 좀 줄여주기 위하여 미리 언급하고 넘어가야 할 것이 있다.


일단 yolo는 기본적으로 yolo저자가 만든 darknet이라는 C기반 딥러닝 프레임워크(?)를 사용했다.


config file에다 모델의 스펙을 기술해주면, C에서 그 모델 모양 그대로 네트워크를 생성해주는 친구라고 대충 생각하면 될 것 같다.


그리고 우리는 yolo의 config파일을 읽어서 C기반 cuda로 export해주는 기존 방식이 아닌 pytorch로 모델을 생성해 주는 방식을 쓸 것이다.



코딩 시작



일단 프로젝트 폴더를 만들자.


그리고 YOLO의 전반적인 구조를 코딩할 darknet.py 파일과 여러 도움을 줄 함수들을 작성할 util.py 파일을 그 안에 생성하자.



Configuration file


cfg파일은 네트워크의 layout을 block단위로 정의해놓은 파일이다.


만약 caffe에 대한 배경지식이 있다면 .protxt 파일이 cfg파일과 비슷하다고 생각할 수 있다.


우리는 official cfg file을 이용 할 것이고, 

https://github.com/pjreddie/darknet/blob/master/cfg/yolov3.cfg

여기에서 다운로드 받을 수 있다.


cfg 파일을 열고 나면, 


[convolutional]
batch_normalize=1
filters=64
size=3
stride=2
pad=1
activation=leaky

[convolutional]
batch_normalize=1
filters=32
size=1
stride=1
pad=1
activation=leaky

[convolutional]
batch_normalize=1
filters=64
size=3
stride=1
pad=1
activation=leaky

[shortcut]
from=-3
activation=linear


이런 소스가 보일 것이다.


각 괄호들은 하나의 네트워크적인 의미를 가지고 있고, 위의 경우 3개의 convolutional layer과 1개의 shortcut을 나타내고 있다.


shortcut은 ResNet등에서 나온 skip connection 과 유사하다.


YOLO에서는 6타입의 layer들이 있다.




Convolutional


[convolutional] batch_normalize=1 filters=64 size=3 stride=1 pad=1 activation=leaky



conv layer은 딱 봐도 알겠다. 

batch_norm을 할 것인지

필터 수는 몇개인지

필터 사이즈는 몇개인지

stride는 몇인지

padding은 얼마를 주는지

activation function은 무엇을 쓰는지 등이다.



Shortcut


[shortcut] from=-3 activation=linear


shortcut layer은 ResNet에서 사용된 것과 유사한 skip connection이다.


from = -3이라는 뜻은 shortcut layer의 output에 3번째 전의 layer을 더해준다는 뜻이다. 기존의 skip connection과 일치한다.



Upsample


[upsample] stride=2


feature map을 stride 배수에 따라 upsample하는 layer이다.



Route


[route] layers = -4 [route] layers = -1, 61

Route layer은 조금의 설명이 필요하다.

route layer의 layers라는 속성은 한개 혹은 두개의 value를 가지고 있다.


layers 속성에 한개의 value만 있을 때는 그 value의 index에 해당하는 feature map의 value만을 output으로 내보낸다.


위의 예시 -4의 경우, 4번째 이전의 layer의 output layer을 내보낸다고 할 수 있다.



만약 두 개의 value가 있을 때는, 그 value들의 layer들의 feature map을 이어붙여서(concatenate) 내보낸다. 위의 예시같은 경우에는 depth dimension의 직전 layer (-1)과 61번째 layer을 내보내는 코드이다.



YOLO


[yolo] mask = 0,1,2 anchors = 10,13, 16,30, 33,23, 30,61, 62,45, 59,119, 116,90, 156,198, 373,326 classes=80 num=9 jitter=.3 ignore_thresh = .5 truth_thresh = 1 random=1


YOLO layer은 이미지의 feature들을 뽑고 난 후에 실질적인 prediction을 하는 layer이다. (특히 YOLO3에서) 디테일하게 알아야 할 몇가지 특성들이 있다.


anchor

bounding box의 width와 height를 예측하여 정답을 맞추는 것이 자연스러워 보이지만, 실제로 돌려보면 unstable gradient를 발생시키는 원인이 된다. 그래서 대신 anchor라고 하는 pre-defined default bounding box를 사용한다. ( 그렇지 않은 경우 log-space transforms 라는걸 사용하기도 한다. ) 


mask

위에는 총 9개의 anchor이 정의되어 있는데, 그 중 mask에 적혀있는 tag에 해당하는 anchor들만 사용한다.

(10,13) , (16,30) , (33,23) 의 anchor만 사용한다는 뜻 !

그렇다면 결국 각 cell은 3개의 box를 예측하게 될 것이다.




Net


[net] # Testing batch=1 subdivisions=1 # Training # batch=64 # subdivisions=16 width= 320 height = 320 channels=3 momentum=0.9 decay=0.0005 angle=0 saturation = 1.5 exposure = 1.5 hue=.1


마지막으로 net이라고 불리는 block인데, 이것은 layer이라고 부르기 보다는 네트워크 전반적인 설정을 구성해주는 block이다.


네트워크의 input size라던지 하는 부분 말이다.





Parsing the configuration file


설정 파일을 만들었다면, 이제 해석하고 실제 모델을 구현하는 코드가 필요하다.


위의 설명에서 이해가 잘 가지 않았더라도, 이를 실제로 해석하고 코드를 만드는 과정 가운데에 실질적인 이해가 될 수 있을것이라고 생각한다.



우선 darknet.py file에 기본적인 import를 하자


from __future__ import division

import torch 
import torch.nn as nn
import torch.nn.functional as F 
from torch.autograd import Variable
import numpy as np


그리고 parse_cfg 파일을 작성할 것인데, configuration file을 input으로 받아서 cfg파일을 해석하는 함수를 작성 할 것이다.


def parse_cfg(cfgfile):
    """
    Takes a configuration file
    
    Returns a list of blocks. Each blocks describes a block in the neural
    network to be built. Block is represented as a dictionary in the list
    
    """

이 함수에서는 cfg 파일의 모든 block을 dict 형태로 저장하는 것을 목표로 한다.


즉, block의 속성들은 전부 key-value pair로 저장된다. 우리가 cfg 파일을 해석하면서 얻어지는 모든 속성들을 계속 dictionary에 추가한 후에, block들을 담은 blocks라는 list를 리턴하는 것이 함수의 목적이 된다.




이제 파일을 읽고 한줄씩 배열에 담자.


# 파일 읽고 전처리하기 file = open(cfgfile, 'r') lines = file.read().split('\n') # store the lines in a list lines = [x for x in lines if len(x) > 0] # get read of the empty lines lines = [x for x in lines if x[0] != '#'] # get rid of comments lines = [x.rstrip().lstrip() for x in lines] # get rid of fringe whitespaces


그리고 그 후에 블록을 담자.

결과 block을 만들어서 blocks에 append하자.


  # 이제 블록을 담자
  block = {}
  blocks = []

  for line in lines:
      if line[0] == "[":               # This marks the start of a new block
          if len(block) != 0:          # If block is not empty, implies it is storing values of previous block.
              blocks.append(block)     # add it the blocks list
              block = {}               # re-init the block
          block["type"] = line[1:-1].rstrip()     
      else:
          key,value = line.split("=") 
          block[key.rstrip()] = value.lstrip() # 띄어쓰기 기준으로 왼쪽 오른쪽 공백 제거
  blocks.append(block)

  return blocks

이렇게 Parsing을 할 수 있다.


Building Block 만들기


이제 위의 parse_cfg 함수에서 return된 list 로부터 실제의 pytorch module을 만들어보자.

우리는 위에서 언급된 6개의 layer들 종류를 module로 옮겨 볼 것이다.


pytorch는 pre-built된 convolutional layer이나 upsample layer 이 존재한다.

그 외의 layer들은 nn.Module class를 extending 해서 직접 구현해야 한다.



create_modules 함수는 parse_cfg로부터 나온 blocks를 입력받는다.


def create_modules(blocks):
    net_info = blocks[0]     #Captures the information about the input and pre-processing    
    module_list = nn.ModuleList()
    prev_filters = 3
    output_filters = []

일단 net_info 변수에 block의 이름을 저장 해 준다.


* nn.ModuleList

우리의 함수는 nn.ModuleList를 리턴 해 줄 것이다. 이 클래스는 nn.Module object들을 포함하고 있는 일반적인 List 와 유사하다.


그렇지만 nn.ModuleList를 nn.Module의 member로 추가하게 되면, nn.ModuleList의 모든 파라미터가 nn.Module의 파라미터로 추가되게 된다. (그냥 리스트로 하면 추가가 안될 것...)



그리고 코드에서, 우리가 새로운 convolutional layer을 정의하면, 우리는 그 kernel의 dimension또한 정의해야 한다. kernel의 height 와 width는 config 파일에 있고, depth는 (depth of the feature map)이전 레이어로부터 알 수 있다. 그 의미는 우리는 계속 convolution layer의 filter 개수를 알고 있어야 한다는 뜻이다. 우리는 이 정보를 prev_filter 이라는 변수에 저장해놓기로 하자. 초기값은 처음 사진이 RGB채널이므로 3으로 세팅하도록 하자. (그리고 filters 라는 변수에 현재 block의 아웃풋을 저장해놓도록 하자)


Route layer은 previous layer들의 feature map을 가져오는 레이어다. (concatenate도 가능하다. ) 만약 route layer 이후에 conv layer이 이어서 등장한다면, 그 conv layer은 route layer의 feature maps를 받게 될 것이다.  또한 route layer 이전에 있는 레이어들은 route layer에 그 feature map을 그대로 가져오게 된다. 다른말로, 우리는 모든 각 레이어의 output을 알고 있어야 한다는 말이다.

우리는 이를 구현하기 위해서 number of output filter을 output_filters 라는 list에 저장 해 놓을 것이다.



이제 blocks 의 원소들을 차례로 살펴보면서 PyTorch module을 각 block마다 생성해주면서 진행하도록 하겠다.


    for index, x in enumerate(blocks[1:]):
        module = nn.Sequential()

        #check the type of block
        #create a new module for the block
        #append to module_list

nn.Sequencialnn.Module object들을 list 형태로 집어넣은 후, 차례대로 실행해주는 object이다.


만약 cfg파일의 convolutional layer을 보면, batch norm이나 leakyReLU activation layer등이 한꺼번에 사용되고 있다는 것을 알 수 있을 것이다. 우리는 이 layer들을 nn.Sequential 의 add_module function을 통해 이어붙여 줄 것이다.


아래의 convolutional layer을 생성하는 부분을 보자.


        if (x["type"] == "convolutional"):
            #Get the info about the layer
            activation = x["activation"]
            try:
                batch_normalize = int(x["batch_normalize"])
                bias = False
            except:
                batch_normalize = 0
                bias = True

            filters= int(x["filters"])
            padding = int(x["pad"])
            kernel_size = int(x["size"])
            stride = int(x["stride"])

            if padding:
                pad = (kernel_size - 1) // 2
            else:
                pad = 0

            #Add the convolutional layer
            conv = nn.Conv2d(prev_filters, filters, kernel_size, stride, pad, bias = bias)
            module.add_module("conv_{0}".format(index), conv)

            #Add the Batch Norm Layer
            if batch_normalize:
                bn = nn.BatchNorm2d(filters)
                module.add_module("batch_norm_{0}".format(index), bn)

            #Check the activation. 
            #It is either Linear or a Leaky ReLU for YOLO
            if activation == "leaky":
                activn = nn.LeakyReLU(0.1, inplace = True)
                module.add_module("leaky_{0}".format(index), activn)

        #If it's an upsampling layer
        #We use Bilinear2dUpsampling
        elif (x["type"] == "upsample"):
            stride = int(x["stride"])
            upsample = nn.Upsample(scale_factor = 2, mode = "bilinear")
            module.add_module("upsample_{}".format(index), upsample)

다른 함수들은 자주 보던 것들인데, upsample은 처음 보는 것이라 upsample의 수행 결과의 example을 가져왔다.


        >>> input_3x3

        tensor([[[[ 1.,  2.,  0.],

                  [ 3.,  4.,  0.],

                  [ 0.,  0.,  0.]]]])


        >>> m = nn.Upsample(scale_factor=2, mode='bilinear') 

        >>> m(input_3x3)

        tensor([[[[ 1.0000,  1.2500,  1.7500,  1.5000,  0.5000,  0.0000],

                  [ 1.5000,  1.7500,  2.2500,  1.8750,  0.6250,  0.0000],

                  [ 2.5000,  2.7500,  3.2500,  2.6250,  0.8750,  0.0000],

                  [ 2.2500,  2.4375,  2.8125,  2.2500,  0.7500,  0.0000],

                  [ 0.7500,  0.8125,  0.9375,  0.7500,  0.2500,  0.0000],

                  [ 0.0000,  0.0000,  0.0000,  0.0000,  0.0000,  0.0000]]]])





Route Layer / Shortcut Layers


이제 Route 와 Shortcut Layer들에 대한 code를 작성하자.


        #If it is a route layer
        elif (x["type"] == "route"):
            x["layers"] = x["layers"].split(',')
            #Start  of a route
            start = int(x["layers"][0])
            #end, if there exists one.
            try:
                end = int(x["layers"][1])
            except:
                end = 0
            #Positive anotation
            if start > 0: 
                start = start - index
            if end > 0:
                end = end - index
            route = EmptyLayer() # 1
            module.add_module("route_{0}".format(index), route)
            if end < 0:
                filters = output_filters[index + start] + output_filters[index + end]
            else:
                filters= output_filters[index + start]

        #shortcut corresponds to skip connection
        elif x["type"] == "shortcut":
            shortcut = EmptyLayer()
            module.add_module("shortcut_{}".format(index), shortcut)

route type의 경우 


필터의 수를 계산할 때 layer에 숫자가 한개인 경우 end를 0으로 만들어서 이전에 저장한 output_filters로부터 숫자 한개만을 불러온다. 만약 layer에 숫자가 두개인 경우 start, end 두 군데서 만든 것을 + 해준다. (이어붙이는 것이므로)


이를 구체적으로 코드로 옮기는 부분에서 설명하고 넘어가야 할 부분이 있다.


주석 1번을 보면

route = EmptyLayer()

route를 정의하는 Empty layer이라는 새로운 레이어가 있다. 이것은


class EmptyLayer(nn.Module):
    def __init__(self):
        super(EmptyLayer, self).__init__()
이렇게 정의된다.


하는것이 아무것도 없는 그냥 레이어인데, 왜 EmptyLayer 따위의 것을 만드는 것일까?

사실 route는 아무 일도 하지 않는것은 아니다. 앞전의 layer을 가져온다거나, 이전 레이어를 이어붙이는(concatenate) 등의 일을 한다.

하지만 주어진 코드를 구현하기 위해서 파이토치에서는 torch.cat이라는 한줄의 코드로 충분하다. 만약 이를 구현하는 모듈을 따로 구현하게되면 과도한 추상화를 야기할 수 있으므로 그냥 Empty layer로 놔둔 후 forward function에서 처리하도록 하자.


다만 output filter에 들어가는 수는 제대로 유지되어야 하므로 

if end < 0:
    #If we are concatenating maps
    filters = output_filters[index + start] + output_filters[index + end]
else:
    filters= output_filters[index + start]

filters를 업데이트하는 해당 코드는 넣었다.


YOLO Layer



마지막으로, YOLO레이어를 위한 코드를 작성하자.


        #Yolo is the detection layer
        elif x["type"] == "yolo":
            mask = x["mask"].split(",")
            mask = [int(x) for x in mask]

            anchors = x["anchors"].split(",") # , 단위로 나누기
            anchors = [int(a) for a in anchors] # 숫자로 바꾸기
            anchors = [(anchors[i], anchors[i+1]) for i in range(0, len(anchors),2)] # 2개 단위로 tuple 만들기
            anchors = [anchors[i] for i in mask] # mask에 쓰여있는 anchor만 남기기

            detection = DetectionLayer(anchors)
            module.add_module("Detection_{}".format(index), detection)

bounding box들을 저장하기 위해 Detection Layer을 만들었는데, 다음과 같이 정의된다.


class DetectionLayer(nn.Module):
    def __init__(self, anchors):
        super(DetectionLayer, self).__init__()
        self.anchors = anchors


그리고 맨 마지막으로 다음 레이어로 넘어가기 위해 정보를 저장한다.

        module_list.append(module)
        prev_filters = filters
        output_filters.append(filters)

이것을 마지막으로 loop을 마친다.


create_modules의 마지막엔 net_infomodule_list를 포함한 튜플을 리턴한다.


return (net_info, module_list)



Testing the code


darknet.py의 마지막에 아래 코드를 적고 실행시켜서 제대로 동작하는지 확인 해 보자.



Python Codeblocks = parse_cfg("cfg/yolov3.cfg")
print(create_modules(blocks))



  (101): Sequential(

    (conv_101): Conv2d(256, 128, kernel_size=(1, 1), stride=(1, 1), bias=False)

    (batch_norm_101): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)

    (leaky_101): LeakyReLU(negative_slope=0.1, inplace)

  )

  (102): Sequential(

    (conv_102): Conv2d(128, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)

    (batch_norm_102): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)

    (leaky_102): LeakyReLU(negative_slope=0.1, inplace)

  )

  (103): Sequential(

    (conv_103): Conv2d(256, 128, kernel_size=(1, 1), stride=(1, 1), bias=False)

    (batch_norm_103): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)

    (leaky_103): LeakyReLU(negative_slope=0.1, inplace)

  )

  (104): Sequential(

    (conv_104): Conv2d(128, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)

    (batch_norm_104): BatchNorm2d(256, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)

    (leaky_104): LeakyReLU(negative_slope=0.1, inplace)

  )

  (105): Sequential(

    (conv_105): Conv2d(256, 255, kernel_size=(1, 1), stride=(1, 1))

  )

  (106): Sequential(

    (Detection_106): DetectionLayer()

  )

))


이런식으로 나온다면 성공



너무 글이 길어져 2탄에서 계속 하겠다.