세상의 변화에 대해 관심이 많은 이들의 Tech Blog search

YOLOv2

|

목차

You Only Look Once: Unified, Real-Time Object Detection

본 글은 Joseph Redmon, Santosh Divvala, Ross Girshick, Ali Farhadi가 2016년에 Publish한 위 논문을 리뷰한 것이며, 추가적으로 구현을 위한 Loss Function 설명과 코드를 첨부하였다.

Unified Detection

YOLO version2는 기본적으로 end-to-end training 방법을 취하고 있으며 학습이 완료되었을 때 실시간으로 테스트가 가능할 만큼 빠른 속도를 보이는 강점을 갖고 있다.
본 글은 416 X 416 이미지를 기준으로 설명을 진행하도록 하겠다.
YOLO의 핵심은 이미지를 Grid Cell로 나누어 각각의 Cell이 Object Detection을 위한 정체성을 갖게끔 만든다는 것이다.
예를 들어 416 X 416의 이미지는 아래와 같은 Darknet이라는 CNN 구조를 거치게 된다.
(참고로 아래는 Pytorch 기준으로 Channels_first를 적용하였다.)

layer size
0 [None, 3, 416, 416]
1 [None, 32, 208, 208]
2 [None, 64, 104, 104]
5 [None, 128, 52, 52]
8 [None, 256, 26, 26]
13 [None, 512, 13, 13]
18 [None, 1024, 13, 13]
19 [None, 1024, 13, 13]
20 [None, 1024, 13, 13]
skip: [None, 64, 26, 26] ->
skip: [None, 256, 13, 13]
21 [None, 1280, 13, 13]
22 [None, 1024, 13, 13]
23 [None, 35, 13, 13]

Output으로 나온 [35, 13, 13]에서 13 X 13은 Grid의 size이다. 35는 추후에 설명하겠다.

출처: https://blog.paperspace.com/how-to-implement-a-yolo-object-detector-in-pytorch/

위 그림과 같이 정리된 13 X 13 = 169개의 Cell은 이제 각각 Object을 detect하기 위한 정체성을 갖게 된다. 만일 실제(Ground-truth: GT) Object의 중심 좌표(center coordinate)가 Cell 내부에 위치한다면, 그 Cell은 해당 Object를 detect할 책임을 지게 되는 것이다.
(If the center of an object falls into a grid cell, that grid cell is reponsible for detecting that object)

각각의 Grid Cell은 이제 5개의 bbox를 예측하게 되고, 각각의 box에 대해 confidence score를 계산하게 된다. 5개는 YOLOv2에서 정한 숫자이고, YOLOv3에선 총 9개가 등장하게 된다.

자세한 설명을 위해 35라는 숫자에 대해 부연 설명을 하도록 하겠다.

\[35 = 5 * (1 + 4 + 2)\]

5 = bbox 개수
1 = box_confidence = P(Object) * IOU_truth_pred
4 = boxes = box coordinates (bounding box 좌표 4개: x, y, w, h)
2 = box_class_probs (예측하고자 하는 class의 개수와 길이가 같다.)

box_confidence는 그 Cell에 Object가 있을 확률에 IOU_truth_pred를 곱하게 되는데, P(Object)는 당연히 0 또는 1이다. 이 값에 GT_bbox(truth)와 pred_bbox(pred)의 IOU를 계산하여 곱해주면 box_confidence가 되는 것이다. P(Object)가 0일 경우 이 값은 물론 0이 된다.

boxes의 경우 bbox 좌표를 뜻하는데, 후에 IOU를 계산할 때에는 이와 같이 중심 좌표(x, y)와 box 길이(w, h)를 기준으로 계산하는 것이 불편하기 때문에 왼쪽 상단 좌표(x1, y1)과 오른쪽 하단 좌표(x2, y2)로 고쳐주도록 한다.

def box_to_corner(box1, box2):
    """
    abs_coord 형식인 bbox의 [x, y, w, h]를 [x1, y1, x2, y2]로 변환한다.
    :param box1: [..., 4]
    :param box2: [..., 4]
    :return: [..., 1] X 8
    """
    b1_x, b1_y, b1_w, b1_h = box1[..., 0], box1[..., 1], box1[..., 2], box1[..., 3]
    b2_x, b2_y, b2_w, b2_h = box2[..., 0], box2[..., 1], box2[..., 2], box2[..., 3]

    b1_x1, b1_x2 = b1_x - b1_w/2, b1_x + b1_w/2
    b1_y1, b1_y2 = b1_y - b1_h/2, b1_y + b1_h/2

    b2_x1, b2_x2 = b2_x - b2_w/2, b2_x + b2_w/2
    b2_y1, b2_y2 = b2_y - b2_h/2, b2_y + b2_h/2

    return b1_x1, b1_x2, b1_y1, b1_y2, b2_x1, b2_x2, b2_y1, b2_y2

참고로 현재 https://github.com/KU-DIA/BasicNet에서 관련 코드를 확인할 수 있다.

이제 box_class_probs를 보면 이 값은 P(Class_i | Object)를 뜻하는데, Object가 있을 경우 i번째 Class일 조건부 확률을 뜻한다. 사실 이 값만으로는 추후 과정 진행이 어렵기 때문에 위에서 구한 box_confidence와 이 box_class_probs를 브로드캐스팅 곱을 통해 계산해주면,

class_scores = box_confidence * box_class_probs
             = P(Object) * IOU * P(Class_i | Object)
             = P(Class_i) * IOU

위와 같이 class_scores를 구할 수 있다.
이 class_scores 텐서는 본인이 설정한 Class 수만큼의 길이를 가지는데(정확히 “길이”는 아니지만), 본 예에서는 2로 설정하였기 때문에 앞으로도 2라는 숫자를 계속 사용하도록 하겠다.

이 class_scores는 각각의 box가 가지는 class-specific confidence score를 의미하게 된다. 만약 설정한 Class를 사람, 고양이라고 한다면, 각 class_scores 텐서는 그 Cell이 “사람”을 detect할 확률, “고양이”를 detect할 확률을 담고 있는 것이다.

이후에 여러 과정이 추가되기는 하지만, 본질적으로 이렇게 표현된 class_scores에서 가장 높은 값을 갖는 class_index를 찾아 output으로 반환하게 된다.

다시 위로 돌아가서 [None, 35, 13, 13] 구조에서, 35는 5 X 7이라는 것을 확인하였다.
바로 위에서 7은 1(box_confidence), 4(bbox 좌표), 2(box_class_probs)로 분해되는 것을 보았는데,
1과 2가 브로드캐스팅 곱을 통해 길이 2의 class_scores로 정리되었다.
위 class_scores는 cell 기준으로 존재하는데, cell 하나당 5개의 bbox를 갖고, 이러한 cell은 총 13 X 13개 있으므로, 이제 우리는 13 X 13 X 5개의 class_scores 텐서를 갖게 되었다.

그런데 그냥 이런식으로 진행하게 되면, 845개의 텐서가 등장하는데, 너무 많다.
이제부터는 텐서의 수를 효과적으로 줄여 학습을 준비하는 과정에 대해 설명하겠다.
사실 아예 삭제하는 것은 아니고, 필요없는 텐서의 값들을 죄다 0으로 바꿔주는 작업이다.

  1. filter_by_confidence
    def filter_by_confidence(confidence, boxes, class_probs, threshold=0.6):
     """
     confidence가 threshold보다 낮은 경우 제거해준다.
     남은 confidence와 class_probs를 곱하여 class_scores를 생성한다.
     :param confidence: (None, 1)
     :param boxes: (None, 4)
     :param class_probs: (None, C)
     :param threshold: 필터링 threshold
     """
     confidence[confidence < threshold] = 0.0
        
     (하략: class_scores를 계산하여 반환함)
    

용어를 정리하자면, confidence = box_confidence, class_probs = box_class_probs이다.
위 함수는 일정 threshold보다 작은 box_confidence를 갖는 box_confidence를 아예 0으로 바꿔준다.
왜냐하면 P(Object)가 0에 근접할 경우, background(Object가 없음)라는 의미인데, 이들의 bbox를 찾는 것은 의미가 없기 때문이다.

  1. Non_max_suppression
    def nms(boxes, class_scores, threshold=0.6):
     """
     :param boxes: bbox 좌표, (None, 4)
     :param class_scores: confidence * class_prob_scores, 클래스 별 score, (None, C)
     :param threshold: NMS Threshold
     """
    
     for class_number in range(C):
         target = class_scores[..., class_number]
    
         # 현재 class 내에서 class_score를 기준으로 내림차순으로 정렬한다.
         sorted_class_score, sorted_class_index = torch.sort(target, descending=True)
    
         # idx: 아래 bbox_max의 Index
         # bbox_max_idx: 정렬된 class_scores를 기준으로 가장 큰 score를 가지는 bbox Index
         for idx, bbox_max_idx in enumerate(list(sorted_class_index.numpy())):
             # 기준 class_score가 0이라면 비교할 필요가 없다.
             # 아래 threshold 필터링에서 0으로 바뀐 값이기 때문이다.
             if class_scores[bbox_max_idx, class_number] != 0.0:
                 # 0이 아니라면 순서대로 criterion_box(bbox_max)로 지정된다.
                 bbox_max = boxes[bbox_max_idx, :]
    
                 # criterion_box(bbox_max)가 아닌 다른 box들을 리스트로 미리 지정한다.
                 #others = [index for index in list(sorted_class_index.numpy()) if index != i]
                 others = list(sorted_class_index.numpy())[idx+1:]
    
                 # 비교 대상 box들에 대해서
                 # bbox_cur_idx: 비교 대상 bbox Index
                 for bbox_cur_idx in others:
                     bbox_cur = boxes[bbox_cur_idx, :]
                     iou = get_iou(bbox_max, bbox_cur)
                     # print(bbox_max_idx, bbox_cur_idx, iou)
    
                     # iou가 threshold를 넘으면 (기준 box와 비교 대상 box가 너무 많이 겹치면)
                     # 그 해당 box의 현재 class의 class_score를 0으로 만들어 준다.
                     if iou > threshold:
                         class_scores[bbox_cur_idx, class_number] = 0.0
    
     return boxes, class_scores
    

사실 Cell 별로 각각 bbox를 5개씩 갖게 되면 인접한 Cell들끼리는 bbox가 마구 겹칠 것이다. 또, bbox의 크기가 충분히 클 경우 이미지 바깥으로 벗어나기도 할 것인데, 한 이미지에 Object 수가 수백개 있는 것이 아닌 이상, 이렇게 많은 bbox는 필요하지 않은 것이 자명하다.

NMS 작업은 이 문제를 효과적으로 해결해준다. 쉽게 말해서 “왕”을 뽑는 느낌인데, 특정 class_scores가 높은 bbox와 과하게 겹치는 (IOU가 높은) 다른 녀석들을 제거하는 것이다.

“사람”을 detect하는 class_scores를 기준으로 class_scores를 내림차순으로 정렬한다. 제일 큰 첫 번째 값과 나머지 값들을 쌍으로 IOU를 계산하여 과하게 겹치는 (기준 threshold)를 넘는 값은 0으로 바꿔준다.

이 과정이 끝나면 [None, 35, 13, 13]이라는 크기 자체는 바뀌진 않지만, 중간중간에 많은 숫자가 0으로 바뀌어 있을 것이다. (1, 2번 기준을 충족하지 못한 값들)
이제 이를 바탕으로 training을 시키면 된다.

Training

위 그림은 YOLOv2의 Loss Function이다. YOLO의 최대 장점은 이렇게 Loss Function을 하나로 통합하여 효과적인 학습을 가능하게 했다는 점이다.

https://curt-park.github.io/2017-03-26/yolo/에서 Loss Function과 관련한 기본적인 설명을 얻을 수 있는데, 추가적으로 설명을 하도록 하겠다.

먼저 1, 2줄은 bbox 좌표에 관한 Loss Function이다. 앞에 있는 $ \lambda_{coord} $는 5로 설정되었다.
$ S^2 $은 Grid Cell의 개수를 의미하며 본 예에서는 13 X 13을 의미한다. $ B $는 정해둔 bbox (anchors) 개수이며 본 예에서는 5를 의미한다. 이렇게 모든 Cell에서 5개 씩의 bbox를 계산하여 GT_bbox와 차이를 좁혀나가는 것이다.

여기서 앵커에 대해 잠깐 설명하자면, 이 앵커는 빠른 학습을 위해 설정된 bbox의 초기값이라고 생각하면 된다. 그냥 무작정 Cell에다가 bbox를 그린다면 그 크기의 편차가 매우 심할 것이다. 미리 object들의 크기를 대략적으로 계산하여 가장 많이 등장할 법한, 가장 유사한 크기의 bbox 크기를 미리 계산해두어 저장한 것이 바로 앵커인데, 이는 보통 Clustering 기법을 통해 미리 계산된다.

이렇게 미리 계산된 앵커를 초기값으로 투입하고, GT_bbox 좌표와의 차이를 빠르게 줄여 나가는 것이 1, 2번째 줄의 목표라고 하겠다.

그리고 $ 1_{i, j}^{obj} $ 라는 Indicator Function의 기능이 매우 중요한데, 이 지시함수는 i번째 Grid Cell에서 j번째 bbox에 Object가 존재할 경우 1이고, 아니면 0이다.

아래의 $ 1{i, j}^{noobj} $ 는 반대의 의미를 가지며, $ 1{i}^{obj} $ 는 오직 Cell 소속 여부와 관련이 있다.

3, 4번째 줄은 Object가 있는지 없는지에 대한 Loss를 계산하게 되고,
5번째 줄은 P(Class_i | Object) = Conditional Class Probability의 Loss를 계산하게 된다.

Conclusion

빠른 속도와 괜찮은 정확도를 가졌지만 YOLOv2의 단점은 작은 물체나 겹치는 물체들을 효과적으로 Localization하지 못한다는 것이다. 이는 version3에서 상당부분 업그레이드 된다.