Home [데브코스] 10주차 - DeepLearning Yolo v3 coding (1)
Post
Cancel

[데브코스] 10주차 - DeepLearning Yolo v3 coding (1)


Yolo v3

Architecture

backbone는 darknet53을 사용했고, 이렇게 나온 feature map과 추가적으로 연산한 후의 feature map들을 다 추출해서 합치게 된다. 이를 통해 크기가 각기 다른 객체들을 잘 탐지할 수 있다. 13x13 feature map에서는 grid가 13일 것이고, 그렇다면 큰 객체를 잘 찾을 수 있고, 52x52 feature map에서는 작은 객체를 잘 찾을 수 있을 것이다.

darknet을 구현하고, 연구한 사람의 github는 다음과 같다. 이 곳을 들어가면 darknet이 구현되어 있는데, c++로 구현되어 있어 pytorch를 사용하는데 있어서 모델을 변경시키거나 커스터마이징하기가 다소 복잡하다. 따라서 직접 구현을 해볼 것이다.


prepare data

  • yolo format yolo 데이터 폴더에는 한 폴더 안에 이미지와 annotation 파일이 함께 있다.
1
2
3
4
5
6
7
8
9
yolodata
  ⊢ train
    ⊢ a.jpg
    ⊢ a.txt
    ⊢ b.jpg
    ⊢ b.txt
    ...
  ⊢ eval
  ∟ test

따라서 jpg 파일들을 먼저 불러온 다음 그에 맞게 해당하는 annotation을 불러오도록 만든다.


yolo의 labeling format은 [class_index, x_center, y_center, width, height]로 되어 있다. 이 때, x,y,w,h는 전체 이미지에 대해 normalize되어 있다. 따라서 전체 이미지를 곱해줘야 실제 위치들이 나타난다.


  • PASCAL VOC format
1
2
3
4
5
6
7
8
9
10
11
12
13
voc20xx
  ⊢ jpegimages
    ⊢ a.jpg
    ⊢ b.jpg
    ⊢ c.jpg
    ...
  ⊢ annotations
    ⊢ a.xml
    ⊢ b.xml
    ⊢ c.xml
  ∟ imagesets
    ⊢ train.txt
    ⊢ eval.txt

pascal voc 의 경우 annotation과 이미지는 다른 폴더에 들어가 있고, imagesets라는 폴더는 train, eval 데이터에 대한 정보들을 저장한 곳이다. train.txt를 보게 되면 경로도 없고, 확장자도 없이 파일명만 넣어져 있다.


  • KITTI format

kitti의 label format은 다음과 같다.

Truck 0.00 0 -1.57 599.41 156.40 629.75 189.25 2.85 2.63 12.34 0.47 1.49 69.44 -1.56

  • type: 총 9개의 클래스에 대한 정보이다. tram은 길거리에 있는 기차를 말하고, misc는 구별하기 어려운 애매한 차량들을 지칭하는 것이고, doncares는 너무 멀리 있어서 점으로 보이거나, 잘려서 보이는 차량들에 대한 클래스로 지정해놓은 것이다.
  • truncated : 차량이 이미지에서 벗어난 정도를 나타낸다. 0~1사이의 값으로 표현된다.
  • occluded : 다른 오브젝트에 의해 가려진 정도를 나타낸다. 0은 전체가 다 보이는 것, 1은 일부만 ,2는 많이 가려진 정도, 3은 아예 안보이는 것으로 나타낸다.
  • alpha : 객체의 각도를 나타낸다.
  • bbox : left, top, right, bottom에 대한 bounding box 좌표를 나타낸다.


더 깔끔한 데이터셋을 만들기 위해 PASCAL VOC format을 사용하여 모델을 구성하고자 한다. 그리고 xml파일이 아닌 txt파일로 사용할 것이다. kitti를 yolo로 바꾸고, 다시 pascal voc format으로 진행할 것이다.

먼저 kitti를 yolo로 변환하는 것에는 convert2Yolo라는 깃허브 사이트가 있어서 이를 클론하여 사용하면 편하다.



annotation이 잘 되어 있는지를 확인해봐야 하므로, labeling tool을 사용하여 확인해봐야 한다. 종류에는 labelimg(https://github.com/tzutalin/labelImg)와 yolo_label(https://github.com/developer0hye/Yolo_Label) 이 있다. window는 후자, linux는 전자의 프로그램을 사용하는 것이 좋을 것 같다. 이 프로그램을 통해 직접 annotation을 제작할 수도 있고, 조도를 바꾸는 등의 다양한 기능을 제공한다.




이제 직접 데이터를 다운받고, 정리를 해주자.

먼저 데이터를 다운받는 방법에는 KITTI Dataset 홈페이지를 들어가서 다운받아도 되지만, 코드로 구현을 해서 다운을 받을수도 있다.

1
2
3
4
5
curl https://s3.eu-central-1.amazonaws.com/avg-kitti/data_object_image_2.zip --create-dirs ./datasets/KITTI/ -o ./datasets/KITTI/img_kitti.zip
cd ./datasets/KITTI && unzip -d . img_kitti.zip

curl https://s3.eu-central-1.amazonaws.com/avg-kitti/data_object_label_2.zip --create-dirs ./datasets/KITTI/train/Annotations -o ./datasets/KITTI/train/Annotations/lab_kitti.zip
cd ./datasets/KITTI/train/Annotations && unzip -d . lab_kitti.zip

curl <url> --create-dirs <folder name to download> -o <dir/zip file name.zip> cd <dir> && unzip -d <dir to upzip> <zip filename.zip>

자신이 다운받고자 하는 폴더의 경로를 설정해서 타이핑하면 된다. 나의 경로는 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
./datasets
  ∟ KITTI
    ⊢ train
      ⊢ Annotations
        ⊢ 000000.txt
        ⊢ 000001.txt
        ⊢ 000002.txt
        ...
      ⊢ ImageSets
        ∟ train.txt
      ∟ JPEGimages
        ⊢ 000000.png
        ⊢ 000001.png
        ⊢ 000002.png
        ...
    ∟ test
      ⊢ ImageSets
        ∟ test.txt
      ∟ JPEGimages
        ⊢ 000000.png
        ⊢ 000001.png
        ⊢ 000002.png
        ...

이미지는 train과 test가 함께 있으므로 KITTI폴더에 다운 받은 후 알맞게 분배하고 이름도 변경해주고, Annotation의 경우 train에 대한 것뿐이므로 train폴더에 풀고 경로에 맞게 집어넣어준다.

그 다음 imageset의 파일들을 생성하기 위해서는 다음과 같은 코드를 사용하여 파일을 만들어준다.

1
datasets/KITTI/train> dir /b /s .\Annotations\*.txt >> ./ImageSets/train.txt


C:\Users\dkssu\dev\datasets\KITTI\train\Annotations\000000.txt
C:\Users\dkssu\dev\datasets\KITTI\train\Annotations\000001.txt
C:\Users\dkssu\dev\datasets\KITTI\train\Annotations\000002.txt
C:\Users\dkssu\dev\datasets\KITTI\train\Annotations\000003.txt
C:\Users\dkssu\dev\datasets\KITTI\train\Annotations\000004.txt
...

여기에서 경로와 확장자를 다 지워준다.

000000
000001
000002
000003
000004
...


test도 동일하게 만들어준다.

1
datasets/KITTI/test> dir /b /s .\JPEGimages\*.png >> ./ImageSets/test.txt
000000
000001
000002
000003
000004
...



configuration

파라미터에 대한 파일은 다음 주소로 들어가 파일을 다운로드한다.

https://github.com/dkssud8150/dev_yolov3/blob/master/config/yolov3.cfg


config 파일에서 network 부분의 config 파라미터만 불러온다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# watch parse the yolov3 configuaraion about network
def parse_hyperparam_config(path):
    file = open(path, 'r')
    lines = file.read().split('\n')
    lines = [x for x in lines if x and not x.startswith('#')] # #으로 시작하지 않는 줄만 저장
    lines = [x.rstrip().lstrip() for x in lines]

    # module에 대한 definition
    module_defs = []

    for line in lines:
        # layer devision
        if line.startswith("["):
          type_name = line[1:-1].rstrip()
            if type_name == "net": # net은 network의 hyperparameter는 저장 x
              module_defs.append({}) # dictionary
              module_defs[-1]['type'] = type_name
              if module_defs[-1]['type'] == 'convolutional':
                module_defs[-1]['batch_normalize'] = 0 # default bat normalize
        
        else:
            if type_name != 'net':
                continue
            key, value = line.split("=")
            module_defs[-1][key.rstrip()] = value.strip()

    return module_defs

#로 되어 있는 것은 주석이므로 제거하고, 나머지를 lines에 저장한다. 좌우 빈칸이 존재할 수 있으므로 삭제해준다. 그 다음으로 [로 시작되는 부분은 아래 부분들이 어떤 부분의 내용인지를 나타내는 것이므로 저장을 해놓아야 한다. net이면 network, 즉 criterion이나 optimizer에 대한 hyperparameter들에 대한 것이고, convolutional은 convolution을 할 때의 filter 사이즈나 stride 값이다.

batch normalize는 0으로 default로 설정하고, network에 대한 것들만 보기 위해서 net 부분에서의 key와 value를 =을 기준으로 입력해준다. 결과를 출력해주면 다음과 같다.

1
[{'type': 'net', 'batch': '4', 'subdivisions': '1', 'width': '640', 'height': '480', 'channels': '3', 'class': '8', 'momentum': '0.9', 'decay': '0.0005', 'angle': '0', 'saturation': '1.5', 'exposure': '1.5', 'hue': '.1', 'ignore_cls': '99', 'learning_rate': '0.01', 'burn_in': '1000', 'max_batches': '500200', 'policy': 'steps', 'steps': '400000,450000', 'scales': '.1,.1'}]


저장한 param들에서 원하는 데이터들만 추출한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# get the data to want to be ours.
def get_hyperparam(data):
    for d in data:
        if d['type'] == 'net':
            batch = int(d['batch'])
            subdivision = int(d['subdivisions'])
            momentum = float(d['momentum'])
            decay = float(d['decay'])
            saturation = float(d['saturation'])
            lr = float(d['learning_rate'])
            burn_in = int(d['burn_in'])
            max_batch = int(d['max_batches'])
            lr_policy = d['policy']
            in_width = int(d['width'])
            in_height = int(d['height'])
            in_channels = int(d['channels'])
            classes = int(d['classes'])
            ignore_class = int(d['ignore_cls'])

            return{'batch': batch,
                   'subdivision': subdivision,
                   'momentum': momentum,
                   'decay': decay,
                   'saturation': saturation,
                   'lr': lr,
                   'burn_in': burn_in,
                   'max_batch': max_batch,
                   'lr_policy': lr_policy,
                   'in_width': in_width,
                   'in_height': in_height,
                   'in_channels': in_channels,
                   'classes': classes,
                   'ignore_class': ignore_class}
        
        else:
            continue

이렇게 저장된 결과물을 출력해보면 다음과 같다.

1
{'batch': 4, 'subdivision': 1, 'momentum': 0.9, 'decay': 0.0005, 'saturation': 1.5, 'lr': 0.01, 'burn_in': 1000, 'max_batch': 500200, 'lr_policy': 'steps', 'in_width': 640, 'in_height': 480, 'in_channels': 3, 'classes': 8, 'ignore_class': 99}


Dataloader

데이터셋을 만들고, loader까지 하기 위해 폴더를 생성하고 거기서 작업을 한다.

1
2
3
4
...
  ⊢ dataloader
    ⊢ __init__.py
    ⊢ yolo_data.py

yolo_data.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import torch
from torch.utils.data import Dataset

class Yolodata(Dataset): # torch utils data의 dataset을 상속받는다.

    # format path
    file_dir = ''
    anno_dir = ''
    file_txt = ''

    base_dir = 'C:\\Users\\dkssu\\dev\\datasets\\KITTI\\'
    # train dataset path
    train_img = base_dir + 'train\\JPEGimages'
    train_txt = base_dir + 'train\\Annotations'
    # valud dataset path
    valid_img = base_dir + 'valid\\JPEGimages'
    valid_txt = base_dir + 'valid\\Annotations'

    class_names = ['Car', 'Van', 'Truck', 'Pedestrian', 'Persion_sitting', 'Cyclist', 'Tram', 'Misc'] # doncare는 x
    num_classes = None
    img_data = []
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
    def __init__(self, is_train=True, transform=None, cfg_param=None):
        super(Yolodata, self).__init__()
        self.is_train = is_train
        self.transform = transform
        self.num_class = cfg_param['classes']

        if self.is_train:
            self.file_dir = self.train_img
            self.anno_dir = self.train_txt
            self.file_txt = self.base_dir + 'train\\ImageSets\\train.txt'
        else:
            self.file_dir = self.valid_img
            self.anno_dir = self.valid_txt
            self.file_txt = self.base_dir + 'valid\\ImageSets\\valid.txt'


        img_names = []

        with open(self.file_txt, 'r', encoding='UTF-8', errors='ignore') as f:
            img_names = [i.replace("\n", "") for i in f.readlines()]
        
        for i in img_names:
            #print(i)
            if os.path.exists(self.file_dir + i + ".jpg"):
                img_data.append(i + ".jpg")
            elif os.path.exists(self.file_dir + i + ".JPG"):
                img_data.append(i + ".JPG")
            elif os.path.exists(self.file_dir + i + ".png"):
                img_data.append(i + ".png")
            elif os.path.exists(self.file_dir + i + ".PNG"):
                img_data.append(i + ".PNG")

        self.img_data = img_data

dataset이 있는 곳의 dir을 base_dir로 정의해주고, 그 안에 있는 image와 annotation 파일들의 경로를 지정한다. 클래스 이름들도 미리 정의를 해주고, 생성과 편집을 편리하게 하기 위해 file_dir, anno_dir, file_txt 변수를 만들어 상황에 맞게 이곳에 집어넣는다.

그 다음 이미지의 이름들이 적힌 ImageSets의 txt파일을 불러와 img_names 리스트에 저장시켜준다. 이 때, replace를 하지 않으면 1줄씩 띄워져서 저장이 되므로 enter를 지우고 저장시킨다. 저장된 파일명을 통해 image_data를 불러온다. 확장자가 어떤 것인지 모르므로 다 적용시켜서 붙여준다.



  • image data
1
2
3
4
5
6
    def __getitem__(self, index):
        img_path = self.file_dir + self.img_data[index]

        with open(img_path, 'rb') as f:
            img = np.array(Image.open(img_path).convert('RGB'), dtype=np.uint8)
            img_origin_h, img_origin_w = img.shape[:2] # img shape : [H,W,C]

이미지 경로를 받아서 이미지 파일을 열어 저장하고, 이미지의 크기도 저장한다.


  • annotation data
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
        # annotation dir이 있는지 확인, txt파일 읽기
        if os.path.isdir(self.anno_dir):
            txt_name = self.img_data[index]
            for ext in ['.png', '.PNG', '.jpg', '.JPG']:
                txt_name = txt_name.replace(ext, '.txt')
            anno_path = self.anno_dir + txt_name

            if not os.path.exists(anno_path):
                return
            
            bbox = []
            with open(anno_path, 'r') as f: # annotation about each image
                for line in f.readlines():
                    line = line.replace("\n",'')
                    gt_data = [l for l in line.split(' ')] # [class, center_x, center_y, width, height]

                    # skip when abnormal data
                    if len(gt_data) < 5:
                        continue

                    cls, cx, cy, w, h = float(gt_data[0]), float(gt_data[1]), float(gt_data[2]), float(gt_data[3]), float(gt_data[4])
                
                    bbox.append([cls, cx, cy, w, h])

            bbox = np.array(bbox)

            # skip empty target
            empty_target = False
            # even if object does not exist in image, we have to put bbox data
            if bbox.shape[0] == 0:
                empty_target = True
                bbox = np.array([[0,0,0,0,0]]) # bbox의 형태가 객체가 2개일경우 [[a,b,c,d,e],[a,b,c,d,e]] 이므로, 형태를 맞추기 위해 [[]]로 생성
            
            # data augmentation
            if self.transform is not None:
                img, bbox = self.transform((img, bbox))

            # 해당 데이터가 몇번째 배치인지 확인하기 위한 index
            # 지금 getitem으로 얻은 데이터는 1개의 이미지에 대한 것인데, 학습을 위해서는 batch size로 묶어야 하므로 index를 추가
            if not empty_target:
                batch_idx = torch.zeros(bbox.shape[0]) # 객체 개수만큼 크기를 생성

                target_data = torch.cat((batch_idx.view(-1,1), bbox), dim=1) # batch index의 x는 1, y는 객체 개수의 array로 만들어줘서, bbox와 concat
            else:
                return
            return img, target_data, anno_path

        else: # test or valid mode
            bbox = np.array([[0,0,0,0,0]])
            if self.transform is not None:
                img, _ = self.transform((img, bbox))
            return img, None, None



  • len
1
2
    def __len__(self):
        return len(self.img_data)



train

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
def collate_fn(batch):
    # only use valid data
    batch = [data for data in batch if data is not None] 
    # skip invalid data
    if len(batch) == 0:
        return
    
    imgs, targets, anno_path = list(zip(*batch))
    # print(targets[0].shape, targets[1].shape)
    imgs = torch.stack([img for img in imgs]) # mk 3dim -> 4dim, 0index = batch

    for i, boxes in enumerate(targets):
        # insert 0 index of box of dataloader function, instead of zero 
        boxes[:,0] = i

    targets = torch.cat(targets,0)
    # print(targets.shape)
    return imgs, targets, anno_path

customdataset을 구성하게 되면 datset은 각 이미지마다의 정보들을 불러온다. 그러나 학습에서는 batch 단위로 학습을 하기 때문에 batch 단위로 만들어줘야 한다. 이것을 위해 collate_fn을 사용한다. 대부분 이 함수를 직접 선언해주고 사용한다.

batch를 인자로 받아 사용되고, batch는 batch크기로 dataset의 값들을 저장해놓은 변수이다. 이에 대해 batch의 각 단위들을 읽어불러오며 비어있지 않은 값들을 저장시킨다. Yolodata에서 리턴하는 값들은 img, target, anno_path이므로 zip(*iterables) 함수를 사용하여 batch size 단위로 묶어서 저장한다.

zip(*iterables) 참고 사이트

list(zip(*batch))를 통해 zip을 통해 같은 index의 위치에 있는 요소들끼리 묶어서 tuple을 만들고, 다 묶은 것들을 list로 묶어놓는다. 이렇게 만들어진 정보들을 stack을 통해 차원 하나를 더 생성한 것이다.

torch.Size([3, 608, 608]) => torch.Size([1, 3, 608, 608])


target의 경우, 우리의 yolodata 클래스를 들어가보면 batch idx 자리가 있는데, 모두 0으로 만들어져 있다. 이것을 0 대신 해당 batch index를 삽입한다. 그리고 targets.shape에 대한 코드들을 출력해보면 다음과 같다.

1
2
torch.Size([1, 6]) torch.Size([8, 6])
torch.Size([9, 6])

확인을 위해 batch를 2로 주었을 때의 출력이고, 위의 size를 먼저보게 되면 1,8이 객체 수를 의미한다. 각 image마다의 객체 수를 1개로 합치는 것이 torch.cat이다. 추후 코드를 짤 때도 batch마다의 총 객체 수를 통해 구현을 할 예정이므로 이렇게 묶어주었다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from torch.utils.data.dataloader import Dataloader

''' train '''
def train(cfg_param = None, using_gpus = None):
    print("train")
    
    # dataloader
    train_dataset = Yolodata(is_train=True, 
                       transform=None, 
                       cfg_param=cfg_param)
    
    train_loader = DataLoader(train_dataset, 
                              batch_size=cfg_param['batch'],
                              num_workers=0,
                              pin_memory=True,
                              drop_last=True,
                              shuffle=True,
                              collate_fn = collate_fn)
    
    for i, batch in enumerate(train_loader):
        img, target_data, anno_path = batch
        print("iter {}, img {}, targets {}, anno_path {}".format(i , img.shape, target_data.shape, anno_path))
        print("gt_data {}".format(target_data))
        break

만든 dataset을 torch.utils.data.dataloader에 있는 Dataloader를 통해 학습에 맞는 형태로 생성한다. 위에서도 말했듯이 collate_fn을 생성해줘야 학습에 맞는 크기로 구성이된다.

  • num_workers : cpu와 gpu의 데이터 교류를 담당하는데, 0 이면 default값으로 single process와 비슷하게 진행된다. 0 이상을 넣게 되면 multi threading 방식으로 진행된다.
  • pin_memory : 이미지나 데이터 array를 gpu로 올릴 때 memory의 위치를 할당할지 말지에 대한 인자이다.


출력을 보게 되면 다음과 같다.

1
iter 0, img torch.Size([1, 375, 1242, 3]), targets torch.Size([1, 5, 6]), anno_path ['C:\\Users\\dkssu\\dev\\datasets\\KITTI\\train\\Annotations\\000545.txt']

target에 대해 조금 더 자세히 보기 위해 000545.txt도 함께 본다. 이 txt의 내용은 다음과 같고, 그 아래는 gt_data즉 target_data를 출력한 것이다.

0 0.906 0.564 0.185 0.212
1 0.499 0.54 0.043 0.139
0 0.589 0.569 0.076 0.095
7 0.421 0.525 0.024 0.039
1 0.42 0.499 0.021 0.077
1
2
3
4
5
gt_data tensor([[[0.0000, 0.0000, 0.9060, 0.5640, 0.1850, 0.2120],
         [0.0000, 1.0000, 0.4990, 0.5400, 0.0430, 0.1390],
         [0.0000, 0.0000, 0.5890, 0.5690, 0.0760, 0.0950],
         [0.0000, 7.0000, 0.4210, 0.5250, 0.0240, 0.0390],
         [0.0000, 1.0000, 0.4200, 0.4990, 0.0210, 0.0770]]],

txt파일과 비교하여 보았을 때 target_data의 1번째는 batch index에 대한 것이고, 2번째는 object index, 3~6은 bbox에 대한 정보가 담겨져 있다.



Data Transform

1
2
3
4
5
6
7
8
9
10
import numpy as np
import cv2
import torch
import torchvision.transforms as transforms

import imgaug as ia
from imgaug import augmenters as iaa
from imgaug.augmentables.bbs import BoundingBox, BoundingBoxesOnImage

from util.tools import xywh2xyxy_np

필요한 패키지들을 import한다. imgaug란 data augmentation(데이터 증강)에 사용되는 툴로 사용법은 깃허브에 나와있다. 일반적으로 transform을 할 때 분류 태스크에서는 큰 문제가 생기지 않으나 object detection의 경우 이미지 크기를 변환시키거나 affine하면 그에 따라 bounding box도 변형되어야 한다. 그러므로 이를 잘 지원해주는 툴이 imgaug 이다. 사용을 위해서는 설치가 필요하다.

1
pip install imgaug


data_transform.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# absolute bbox
class AbsoluteLabels(object):
    def __init__(self,):
        pass

    def __call__(self, data): # yolodata코드에서 transform이 들어갈 때 (img, bbox)가 data로 들어옴
        img, label = data
        h, w , _ = img.shape
        label[:,[1, 3]] *= w # cx, w *= w
        label[:,[2, 4]] *= h # cy, h *= h

        return img, label

# relative bbox
class RelativeLabels(object):
    def __init__(self,):
        pass

    def __call__(self, data):
        img, label = data
        h, w, _ = img.shape
        label[:,[1, 3]] /= w
        label[:,[2, 4]] /= h
        return img, label

annotation 파일에 있는 데이터들은 이미지 크기에 대해 정규화되어 있다. 이것을 transform하기 위해 정규화를 풀어줘야 한다. 그래야 transform을 해도 정보를 잃지 않는다. 우리는 data를 (img, bbox)의 형태로 집어넣기 때문에 이에 대해 각각을 처리해준다.

정규화를 풀어주고 transform을 하고 난 후에는 다시 상대적 값으로 변환해줘야 한다.


1
2
3
4
5
6
7
8
9
class ResizeImage(object):
    def __init__(self, new_size, interpolation = cv2.INTER_LINEAR):
        self.new_size = tuple(new_size)
        self.interpolation = interpolation

    def __call__(self, data):
        img, label = data
        img = cv2.resize(img, self.new_size, interpolation=self.interpolation)
        return img, label

이미지 data augmentation을 위해 resize하는 class를 정의한다. 이 때, interpolation이란 보간을 의미하는데, 이미지를 변환할 때 생기는 빈 공간등을 처리하는 방식을 정의한다. 그리고, cv2 툴을 사용하여 resize하는데, label은 어차피 상대적인 값이므로 resize를 하지 않아도, 나중에 resize된 w,h를 곱해주면 자동으로 적용이 된다.


1
2
3
4
5
6
7
8
9
class ToTensor(object):
    def __init__(self,):
        pass
    def __call__(self, data):
        img, label = data
        img = torch.tensor(np.transpose(np.array(img, dtype=float) / 255, (2,0,1)), dtype=torch.float32) # normalize, transpose HWC to CHW
        label = torch.FloatTensor(np.array(label))

        return img, label

학습을 위해서는 tensor형태로 변환해줘야 한다. 우리가 가지고 있는 image 차원은 [H,W,C]인데, tensor의 차원은 [C,H,W]이어야 하므로 이를 transpose해야 하고, 이미지들을 정규화하여 집어넣게 된다.

label은 tensor로만 변환하여 리턴한다.


1
2
3
4
5
6
7
8
9
# util/tools.py
def xywh2xyxy_np(x:np.array):
    y = np.zeros_like(x)
    y[...,0] = x[...,0] - x[...,2] / 2 # centerx - w/2 = minx
    y[...,1] = x[...,1] - x[...,3] / 2 # miny
    y[...,2] = x[...,0] + x[...,2] / 2 # maxx
    y[...,3] = x[...,1] + x[...,3] / 2 # maxy

    return y

이 부분은 편의를 위해 정의해놓은 함수로 tools.py에 위치시켜놓는다. 현재 우리가 가지고 있는 bbox는 [center_x, center_y, w, h] 이므로 imgaug의 포맷을 맞춰주기 위해 [min x, min y, max x, max y] 의 형태로 바꾼다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
# augmentation template, 
# 앞으로 다른 augmentation을 사용할 때 이 template을 상속받아서 구현할 것이다. 공통적으로 augmentation을 할 때마다 bbox가 augmentation방식에 따라 값이 변해야 하므로
class ImgAug(object):
    def __init__(self, augmentations=[]):
        self.augmentations = augmentations
    
    def __call__(self, data):
        # unpack data
        img, labels = data

        # convert xywh to minx,miny,maxx,maxy because of imgAug format
        boxes = np.array(labels)
        boxes[:,1:] = xywh2xyxy_np(boxes[:,1:]) # 0 index is class info

        # convert bbox to imgaug format
        bounding_boxes = BoundingBoxesOnImage(
                                [BoundingBox(*box[1:], label=box[0]) for box in boxes],
                                shape=img.shape)

        #apply augmentation
        img, bounding_boxes = self.augmentations(image=img,
                                                 bounding_boxes=bounding_boxes)

        # 예외 처리, 이미지 밖으로 나가는 bounding box를 제거
        bounding_boxes = bounding_boxes.clip_out_of_image()

        # convert bounding boxes to np.array()
        boxes = np.zeros((len(bounding_boxes), 5)) # memory assignment
        for box_idx, box in enumerate(bounding_boxes):
            x1, y1, x2, y2 = box.x1, box.y1, box.x2, box.y2 # x1,y1,x2,y2 멤버 변수를 가지고 있음

            # return [x, y, w, h], 원래의 포맷은 xywh이므로 다시 변환
            boxes[box_idx, 0] = box.label
            boxes[box_idx, 1] = (x1 + x2) / 2
            boxes[box_idx, 2] = (y1 + y2) / 2
            boxes[box_idx, 3] = x2 - x1
            boxes[box_idx, 4] = y2 - y1

        return img, boxes


class DefaultAug(ImgAug):
    def __init__(self,):
        self.augmentations = iaa.Sequential([
                                    iaa.Sharpen(0.0, 0.1),
                                    iaa.Affine(rotate=(-0,0), translate_percent=(-0.1, 0.1), scale=(0.8, 1.5))
                                    ])

imgAug 클래스는 추후에 우리가 적용시킬 다양한 augmentation의 템플릿으로 사용할 용도로 정의한 것으로 image와 label을 정의한 augmentation을 적용시켜서 리턴해주는 방식이다. 각기 다른 augmnetation 클래스마다 작성해줄 수 있지만, 이렇게 하면 코드도 길어지고, 반복적인 작업을 줄일 수 있다. 먼저 numpy형태로 변환해준 bbox들을 위에서 정의해놓은 xywh2xyxy_np를 사용하여 변환시켜준다. 그렇게 변환된 [minx,miny,maxx,maxy] 형태의 bbox들은 imgaug를 사용하여 imgaug 포맷으로 변환한다. 그 후 미리 정의한 augmentation을 적용 시키고, 이미지 밖으로 나가는 boundingbox 좌표들은 제거한다. 그 후 원래의 형태인 xywh 포맷으로 변환시켜준 후 리턴한다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def get_transformations(cfg_param = None, is_train = None):
    if is_train:
        data_transform = transforms.Compose([AbsoluteLabels(),
                                             DefaultAug(),
                                             RelativeLabels(),
                                             ResizeImage(new_size = (cfg_param['in_width'], cfg_param['in_height'])),
                                             ToTensor(),
        ])
    else:
        data_transform = transforms.Compose([AbsoluteLabels(),
                                             DefaultAug(),
                                             RelativeLabels(),
                                             ResizeImage(new_size = (cfg_param['in_width'], cfg_param['in_height'])),
                                             ToTensor(),
        ])

    return data_transform

다 정의해준 클래스들을 통해 transform 묶음에 집어넣는다.


1
2
3
4
5
    for i, batch in enumerate(train_loader):
        img, target_data, anno_path = batch

        drawBox(img[0].detach().cpu()) # detach는 backprop나 등등의 torch의 graph에서 떼내는 것이고, gpu이면 cpu로 데이터를 복사
        break

그렇게 augmentation이 적용된 이미지를 살펴보기 위해 drawbox 함수를 만들어서 선언한다. drawBox 함수는 아래와 같다.

1
2
3
4
5
6
7
8
9
10
def drawBox(img):
    img = img * 255

    if img.shape[0] == 3:
        img_data = np.array(np.transpose(img, (1,2,0)), dtype=np.uint8)
        img_data = Image.fromarray(img_data)

    # draw = ImageDraw.Draw(img_data)
    plt.imshow(img_data)
    plt.show()

채널이 3, 즉 컬러 이미지라면 원래 형태는 tensor(C,H,W)이므로 이를 다시 표현하기 위해 (W,H,C)로 변화하고 0~255에 대한 데이터타입으로 uint8로 지정한다.



Architecture

util/tools.py

yolov3.cfg에서 convolutional에 대한 것만 추출한다. 방식은 network에 대한 module_defs를 추출하는 것과 동일하다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# parse model layer configuration
def parse_model_config(path):
    file = open(path, 'r')
    lines = file.read().split('\n')
    lines = [x for x in lines if x and not x.startswith('#')]
    lines = [x.rstrip().lstrip() for x in lines]

    module_defs = []
    type_name = None
    for line in lines:
        if line.startswith("["):
            type_name = line[1:-1].rstrip()
            if type_name == 'net':
                continue
            module_defs.append({})
            module_defs[-1]['type'] = type_name
            if module_defs[-1]['type'] == 'convolutional':
                module_defs[-1]['batch_normalize'] = 0

        else:
            if type_name == "net":
                continue
            
            key, value = line.split('=')
            value = value.strip()
            module_defs[-1][key.rstrip()] = value.strip()

    return module_defs



model/yolov3.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import os, sys
import numpy as np
import torch
import torch.nn as nn

from util.tools import *

class Darknet53(nn.Module):
    def __init__(self, cfg, param):
        super().__init__()
        self.batch = int(param['batch'])
        self.in_channels = int(param['in_channels'])
        self.in_width = int(param['in_width'])
        self.in_height = int(param['in_height'])
        self.n_classes = int(param['classes'])
        self.module_cfg = parse_model_config(cfg)
        self.module_list = self.set_layer(self.module_cfg)

    def set_layer(self, layer_info):
        module_list = nn.ModuleList()
        in_channels = [self.in_channels]
        for layer_idx, info in enumerate(layer_info):
            print(layer_idx, info['type'])

이를 출력해보면 다음과같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
0 convolutional
1 convolutional
2 convolutional
3 convolutional
4 shortcut
5 convolutional
6 convolutional
7 convolutional
8 shortcut
...
81 convolutional
82 yolo
83 route
84 convolutional
85 upsample
86 route
87 convolutional
88 convolutional
89 convolutional
90 convolutional
91 convolutional
92 convolutional
93 convolutional
94 yolo
95 route
96 convolutional
97 upsample
98 route
99 convolutional
100 convolutional
...

다음과 같이 convolutional과 yolo, route, upsample 등의 layer로 구성되어 있다.

여기서 convolutional은 우리가 아는 convolution에 대한 값들이다. 그리고, shortcut와 route는 그림과 함께 살펴봐야 한다.

먼저 그림에서 봤을 때, 분홍색 부분이 residual block이다. residual block이란 지난 값을 연산 후의 값과 더하여 연산량을 줄이는 방식을 말한다. 식은 g(x) = F(x) + x이고, 이전 layer의 출력값을 x, 연산에 의한 값을 f(x), 현재 출력값을 g(x)라고 한다. 자세한 내용은 https://coding-yoon.tistory.com/141 이 사이트를 참고하는 것이 좋을 것 같다. 그래서 residual block에서 지난 값 x를 가져올 때 shortcut의 값만큼 이전의 layer에서의 값을 가져온다는 것이다. 즉 shortcut가 -3이면 현재 layer에서 3개 layer이전의 값인 x를 더하여 g(x)를 구한다.

route도 얼마나 이전의 layer인지를 뜻하는 것으로, 그림에서 3번째 강아지 사진과 2번째 사진 사이에 * 연산을 하는 곳이 있다. 이것은 concat한다는 의미로, route = -4이면 4개 이전의 layer의 feature map과 concat하는 것이고, route = 95 이면 95번째 layer의 값을 concat한다는 것이다.

좀 더 자세히 설명하기 위해 config 파일을 가져와보았다.

[shortcut]
from=-3
activation=linear

######################

[route]
layers = -4

...

[route]
layers = -1, 61

shortcut layer의 경우 from=-3이라 되어 있다. 이는 3개 이전의 layer와 더한다는 것이다. 그러나 route의 경우 2가지 경우가 있다. layers = -4인 경우에는 그냥 4개 이전의 layer의 feature map을 그대로 가져와 input으로 사용하겠다는 의미이고, layer = -1, 61이면 1개 이전의 layer와 61번째 layer를 concat하겠다는 의미이다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
    def set_layer(self, layer_info): # init layer setting
        module_list = nn.ModuleList() # layer를 하나하나 정의하는 것은 너무 오래 걸리고 옛날방식이다. module에 대한 list를 만들어주는 메서드
        in_channels = [self.in_channels] # first channels of input
        for layer_idx, info in enumerate(layer_info): 
            modules = nn.Sequential() # 많은 layer들을 하나의 묶음으로 만들어주는 메서드
            if info['type'] == 'convolutional':
                make_conv_layer(layer_idx, modules, info) # conv layer add
                in_channels.append(int(info['filters'])) # store each module's input channels
            elif info['type'] == 'shortcut':            
                make_shortcut_layer(layer_idx, modules)   # shortcut layer add
                in_channels.append(in_channels[-1])
            elif info['type'] == 'route':
                make_route_layer(layer_idx, modules)      # route layer add
                layers = [int(y) for y in info['layers'].split(',')]                    # layers에 있는 정보들을 다 가져옴
                if len(layers) == 1:                                 
                    in_channels.append(in_channels[layers[0]])                          # 1개인 경우에는 거기에 있는 feature map을 그대로 사용
                elif len(layers) == 2:
                    in_channels.append(in_channels[layers[0]] + in_channels[layers[1]]) # 2개인 경우 concat
                
            elif info['type'] == 'upsample':
                make_upsample_layer(layer_idx, modules, info) # upsample layer add
                in_channels.append(in_channels[-1]) # width, height만 커지므로 channel은 동일

            elif info['type'] == 'yolo':
                yololayer = Yololayer(info, self.in_width, self.in_height, self.training)
                modules.add_module('layer_'+str(layer_idx)+'_yolo', yololayer)
                in_channels.append(in_channels[-1])
                
            module_list.append(modules)
        return module_list


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
def make_conv_layer(layer_idx : int, modules : nn.Module, layer_info : dict, in_channels : int):
    filters = int(layer_info['filters']) # output channel size
    size = int(layer_info['size']) # kernel size
    stride = int(layer_info['stride'])
    pad = (size - 1) // 2 # layer_info['pad']
    modules.add_module('layer_'+str(layer_idx)+'_conv',
                        nn.Conv2d(in_channels, filters, size, stride, pad))

    if layer_info['batch_normalize'] == '1':
        modules.add_module('layer_'+str(layer_idx)+'_bn',
                            nn.BatchNorm2d(filters))

    if layer_info['activation'] == 'leaky':
        modules.add_module('layer_'+str(layer_idx)+'_act',
                            nn.LeakyReLU())
    elif layer_info['activation'] == 'relu':
        modules.add_module('layer_'+str(layer_idx)+'_act',
                            nn.ReLU())

def make_shortcut_layer(layer_idx : int, modules : nn.Module):
    modules.add_module('layer_'+str(layer_idx)+"_shortcut", nn.Identity()) # modulelist에서 info 타입이 맞지 않으면 복잡해지므로 빈 공간으로 init

def make_route_layer(layer_idx : int, modules : nn.Module):
    modules.add_module('layer_'+str(layer_idx)+"_route", nn.Identity())

def make_upsample_layer(layer_idx : int, modules : nn.Module, layer_info : dict):
    stride = int(layer_info['stride'])
    modules.add_module('layer_'+str(layer_idx)+'_upsample',
                        nn.Upsample(scale_factor=stride, mode='nearest'))

add_module 메서드는 (이름, 함수)로 구성되어 있어 각각의 역할에 대한 이름과 기능을 추가한다. config 파일에서 conv layer 관련해서는 batch_normalize, activation 등이 존재했기 때문에 이 또한 추가해주었다.

shortcut와 route는 기본적으로 구성되어야 할 함수가 없으므로 빈 공간으로 집어넣어 주었다. Identity를 하면 입력을 그대로 출력으로 내뱉어준다.

upsample layer의 경우 stride가 존재하고, upsample에 대한 torch 함수가 지원되므로 이를 사용한다. upsample을 할 때 빈 공간을 어떻게 채울지에 대한 mode도 설정해준다.


  • yolo layer

yolo에 대한 layer는 연산이 복잡하기 때문에 따로 class를 구현했다. yolov3.cfg 파일에서 yolo에 대한 정보는 다음과 같고, mask과 anchor는 anchor가 총 9개가 정의되어 있는데, 각 yolo layer마다 9개 모두를 사용하지 않는다. mask에 해당하는 index의 anchor들만 사용한다. 그래서 아래의 경우는 3,4,5 즉 50,71~ 43,212 만을 사용한다. 50,71은 anchor 박스의 크기를 나타내는 것으로 width = 50, height = 71이다.

classes는 class의 개수이고, ignore_thresh는 loss 계산할 때 박스들이 threshold이상인 것만 positive로 정의하고, 나머지는 negative로 정의할 때 사용되는 값이다.

[yolo]
mask = 3,4,5
anchors = 15,36,  30,49,  19,115,  50,71,  76,106,  43,212, 115,145, 129,230, 194,280
classes=8
num=9
jitter=.3
ignore_thresh = .5
truth_thresh = 1
random=1
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class Yololayer(nn.Module):
    def __init__(self, layer_info : dict, in_width : int, in_height : int, is_train : bool):
        super(Yololayer, self).__init__()
        self.n_classes = int(layer_info['classes'])
        self.ignore_thresh = float(layer_info['ignore_thresh'])
        self.box_attr = self.n_classes + 5                     # output channel = box[4] + objectness[1] + class_prob[n]
        mask_idxes = [int(x) for x in layer_info['mask'].split(',')]
        anchor_all = [int(x) for x in layer_info['anchors'].split(',')] # w1,h1 , w2,h2 , w3,h3 , ... 로 되어 있으므로 이것을 다시 w,h 를 묶어줘야 한다.
        anchor_all = [(anchor_all[i], anchor_all[i+1]) for i in range(0, len(anchor_all), 2)]
        self.anchor = torch.tensor([anchor_all[x] for x in mask_idxes])
        self.in_width = in_width
        self.in_height = in_height
        self.stride = None   # feature map의 1 grid가 차지하는 픽셀의 값 == n x n
        self.lw = None
        self.lh = None
        self.is_train = is_train

    def forward(self, x): # bounding box를 뽑을 수 있게 sigmoid나 exponantional을 취해줌
        # x is input. [N C H W]
        self.lw, self.lh = x.shape[3], x.shape[2] # feature map's width, height
        self.anchor = self.anchor.to(x.device) # 연산을 할 때 동일한 곳에 올라가 있어야함, cpu input이라면 cpu에, gpu input이라면 gpu에
        self.stride = torch.tensor([torch.div(self.in_width, self.lw, rounding_mode = 'floor'), 
                                    torch.div(self.in_height, self.lh, rounding_mode = 'floor')]).to(x.device) # stride = input size / feature map size

        # if kitti data, n_classes = 8, C = (8 + 5) * 3 = 39, yolo layer 이전의 filters 즉 output channels을 보면 다 39인 것을 확인할 수 있다.
        # [batch, box_attrib * anchor, lh, lw] ex) [1,39,19,19]
        # 4dim -> 5dim [batch, anchor, lh, lw, box_attrib]
        x = x.view(-1, self.anchor.shape[0], self.box_attr, self.lh, self.lw).permute(0,1,3,4,2).contiguous() # permute를 통해 dimension 순서를 변경, configuouse를 해야 바뀐채로 진행됨
        return x

stride의 경우 convolutional에서 사용되는 stride와는 다르다. 이 때의 stride는 feature map에서 grid로 나뉘어져 있는데, 이 한 grid가 차지하는 픽셀의 크기를 의미한다.

x가 현재에는 4dimension [batch, box_attribute * number of anchor, lh, lw] 을 가지고 있는데, 이를 보기 편하도록 5dim [batch, anchor, lh, lw, box_attrib] 으로 변환한다. 이를 위해 view를 통해 변환해주고, box_attrib의 속성을 맨 뒤로 보내주기 위해 permute 메서드를 사용한다. 그 후 실제 변환을 유지시키기 위해 contiguous()도 추가한 후 리턴한다.


1
2
3
4
    model = Darknet53(args.cfg, cfg_param, training=True)

    for name, param in model.named_parameters():
        print("name : {}, shape : {}".format(name, param.shape))

이것들을 입력받아 출력을 해보면 다음과 같이 출력된다.

name : module_list.0.layer_0_conv.weight, shape : torch.Size([32, 3, 3, 3])
name : module_list.0.layer_0_conv.bias, shape : torch.Size([32])
name : module_list.0.layer_0_bn.weight, shape : torch.Size([32])
name : module_list.0.layer_0_bn.bias, shape : torch.Size([32])
name : module_list.1.layer_1_conv.weight, shape : torch.Size([64, 32, 3, 3])
name : module_list.1.layer_1_conv.bias, shape : torch.Size([64])
...
name : module_list.104.layer_104_bn.bias, shape : torch.Size([256])
name : module_list.105.layer_105_conv.weight, shape : torch.Size([39, 256, 1, 1])
name : module_list.105.layer_105_conv.bias, shape : torch.Size([39])


추가적으로 입력의 이미지 크기가 608 x 608 이라 했을 때, 이것들이 network들을 거치면서 1/2씩 크기가 작아진다. 그래서 304, 152, 76, 38, 19 로 작아진다. 그래서 4번 reshape이 되어도 계속 짝수가 되도록 만들어줘야 한다.

그리고 yolov3에서는 출력이 총 3번 이루어지는데, 19x19, 38x38, 76x76으로 output이 만들어진다. 출력에 대한 것은 뒤에 다시 살펴보도록 하자.


위의 부분들이 잘 마무리되었으면 이제 실제 모델 학습에 사용될 forward를 구현해야 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class Darknet53(nn.Module):
    ...

    def forward(self, x):
        yolo_result = [] # 최종 output들은 yolo layer에서 나옴
        layer_result = [] # shortcut, route에서 사용하기 위해 저장

        for idx, (name, layer) in enumerate(zip(self.module_cfg, self.module_list)):
            if name['type'] == 'convolutional':
                x = layer(x)
                layer_result.append(x)
            elif name['type'] == 'shortcut':
                x = x + layer_result[int(name['from'])]
                layer_result.append(x)
            elif name['type'] == 'yolo':
                yolo_x = layer(x)
                layer_result.append(yolo_x)
                yolo_result.append(yolo_x)
            elif name['type'] == 'upsample':
                x = layer(x)
                layer_result.append(x)
            elif name['type'] == 'route':
                layers = [int(y) for y in name['layers'].split(',')]
                x = torch.cat([layer_result[l] for l in layers], dim=1)
                layer_result.append(x)
            print("idx : {}, result : {}".format(idx, layer_result[-1].shape))
        return yolo_result

미리 전부 선언해주었기 때문에 forward는 다소 단순하게 구현할 수 있다. 미리 만들어둔 layer들과 module_cfg 파일의 값들을 1줄씩 함께 읽어나가며 연산을 진행한다. type에 따라 식들이 달라질 수 있으므로 다 처리해주고, 우리가 실제로 출력받는 값들은 yolo layer에서 출력이 되므로 이에 대한 값들을 저장하기 위홰 yolo_result 리스트를 선언하고, shortcut이나 route에서 이전 값들을 재사용해야 하므로 layer의 출력값들을 저장할 수 있는 layer_result도 만들어준다.

shortcut이나 route의 경우 미리 만들어둔 layer를 사용하지 않고도 연산이 가능한데, 이 이유는 위에서도 말했듯이 zip을 진행하는데 두 개의 list의 크기가 맞지 않으면 연산이 복잡해지기 때문에 빈 공간으로 선언해둔 것이다.

이들을 직접 print해보면 다음과 같은 출력들을 볼 수 있다.

idx : 0, result : torch.Size([1, 32, 608, 608])
idx : 1, result : torch.Size([1, 64, 304, 304])
idx : 2, result : torch.Size([1, 32, 304, 304])
idx : 3, result : torch.Size([1, 64, 304, 304])
idx : 4, result : torch.Size([1, 64, 304, 304])
...
idx : 103, result : torch.Size([1, 128, 76, 76])
idx : 104, result : torch.Size([1, 256, 76, 76])
idx : 105, result : torch.Size([1, 39, 76, 76])
idx : 106, result : torch.Size([1, 3, 76, 76, 13])

총 106층의 layer가 존재하고, 그에 맞게 각각의 결과들이 존재한다. 결과의 shape은 [batch, channels, height, width] 이다.

1
2
3
4
5
6
7
8
9
10
11
12
    model = Darknet53(args.cfg, cfg_param, training=True)

    model.train()
    for name, batch in enumerate(train_loader):
        img, targets, anno_path = batch
        output = model(img)

        # yolo layer는 3개, 19x19 grid, box attr가 8개 클래스와 box 4와 objectness 1, 3개의 anchor, batch 1
        # 19x19, 38x38, 76x76의 grid를 가지는 출력값 3개를 받는다.
        print("shape : {} {} {}".format(output[0].shape, output[1].shape, output[2].shape))
        
        sys.exit(1)

결과를 출력해보면 다음과 같은 shape들을 볼 수 있다.

shape : torch.Size([1, 3, 19, 19, 13]) torch.Size([1, 3, 38, 38, 13]) torch.Size([1, 3, 76, 76, 13])

우리의 입력은 608x608이다. 맞춰주기 위해 config파일에서 width, height를 608로 수정하였다. input size는 위의 idx0 에서 확인할 수 있다. 이를 $ 2^5 $로 나누게 되면 19x19 이 된다. 19x19, 38x38, 76x76의 grid에 대한 출력을 받을 수 있게 된다. 1은 batch size, 3은 anchor 개수, 13은 n_classes[8] + objectness score[1] + box point[4]이다.



  • weight initialize

모델의 파라미터들을 초기화하여 조금 더 나은 학습 환경을 만들어준다. 초기화 방법은 kaiming_uniform_을 사용한다. 이에 대해서는 다음 url을 참고하여 더 자세히 볼 수 있다.

https://pytorch.org/docs/stable/nn.init.html?highlight=kaiming#torch.nn.init.kaiming_uniform_

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# model/yolov3.py
    def initialize_weights(self):
        # track all layers
        for m in self.modules():
            if isinstance(m, nn.Conv2d):
                nn.init.kaiming_uniform_(m.weight) # weight initializing

                if m.bias is not None:
                    nn.init.constant_(m.bias, 0)
            elif isinstance(m, nn.BatchNorm2d):
                nn.init.constant_(m.weight, 1)  # scale
                nn.init.constant_(m.bias, 0)    # shift
            elif isinstance(m, nn.Linear):
                nn.init.kaiming_uniform_(m.weight)
                nn.init.constant_(m.bias, 0)
1
2
3
# yolov3.py
    model.train()
    model.initialize_weights()



Train

train을 위해 새로운 directory를 생성한다.

1
2
3
...
    ⊢ __init__.py
    ∟ train.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
# train.py
import os, sys
import torch
import torch.optim as optim

from util.tools import *

class Trainer:
    def __init__(self, model, train_loader, eval_loader, hyparam, device, torchwriter):
        self.model = model
        self.train_loader = train_loader
        self.eval_loader = eval_loader
        self.max_batch = hyparam['max_batch']
        self.device = device
        self.epoch = 0
        self.iter = 0
        self.optimizer = optim.SGD(model.parameters(), lr=hyparam['lr'], momentum=hyparam['momentum'])

        self.scheduler_multistep = optim.lr_scheduler.MultiStepLR(self.optimizer, 
                                                             milestones=[20,40,60],
                                                             gamma = 0.5)


    def run_iter(self):
        for i, batch in enumerate(self.train_loader):
            # drop the batch when invalid values
            if batch is None:
                continue

            input_img, targets, anno_path = batch
            print(input_img.shape, targets.shape)
            

    def run(self):
        while True:
            self.model.train()
            self.run_iter()
            
            self.epoch += 1
1
2
3
# yolov3.py
    train = Trainer(model = model, train_loader = train_loader, eval_loader=None, hyparam=cfg_param)
    train.run()

실제 학습을 진행할 코드로 model, train_loader, valid_loader, cfg파일 내용을 가져온다. cfg파일에 max_batch라는 내용도 있는데, 이는 최대 batch를 나타낸다. 즉 몇 batch까지만 진행을 할 것인지에 대한 내용이다.

최적화 함수로는 SGD를 사용하고, lr은 cfg파일에 있는 그대로 사용하고, momentum또한 작성되어 있는대로 사용한다.

scheduler도 선언해주었는데, 이는 학습을 진행할 때마다 learning rate값이 줄어들어야 더 정교하게 학습이 가능하다. 떨어지는 빈도를 설정해주어야 하는데, 우리는 MultiStepLR을 사용하였다. milestones의 경우 언제 learning rate를 수정할 것인지에 대한 것이고, gamma는 얼마나 수정시킬 것인지에 대한 값이다.

지정해준 trainer를 run하게 되면 학습이 진행된다. 그러나 이는 epoch 단위로 돌아가게 되어있고, run_iter에서 각각의 batch마다 학습을 진행하도록 만들어진다. yolo_data.py에서 train_loader가 가지고 있는 값들에는 img, target_data, anno_path이므로 이를 받아주어 출력해보면 다음과 같다.

torch.Size([1, 3, 608, 608]) torch.Size([1, 4, 6])
torch.Size([1, 3, 608, 608]) torch.Size([1, 6, 6])
torch.Size([1, 3, 608, 608]) torch.Size([1, 4, 6])
torch.Size([1, 3, 608, 608]) torch.Size([1, 2, 6])

앞은 input size에 대한 정보이고, 두번째는 target에 대한 정보이다. [batch, number of object, box attrib + class info]로 구성되어 있다.



This post is licensed under CC BY 4.0 by the author.

[데브코스] 10주차 - DeepLearning Object Detection

[데브코스] 10주차 - DeepLearning Yolo v3 coding (2)