SSD 모델

모델 명 특징
SSD300 입력 이미지의 크기가 300x300, 작은 객체를 인식하는 데 강점
SSD500 입력 이미지의 크기가 500 x500, 큰 객체를 인식하는 데 강점
SSD-MobileNet MobileNet을 기반으로 학습된 모델, 모바일 기기에서도 빠르게 실행될 수 있게 경량화된 모델
SSD-ResNet ResNet을 기반으로 학습된 모델, ResNet을 적용해 높은 정화도를 갖는 모델

<aside> 📢 ResNet-34 모델을 특징 추출 모델로 사용하는 SSD512로 실습을 진행

</aside>

#SSD512 특징 추출 네트워크 정의
from torch import nn
from collections import OrderedDict

#ResNet-34: 입력 줄기(layer0)와 네 개의 스테이지(layer1, layer2, layer3, layer4)로 구성
class SSDBackbone(nn.Module):
    def __init__(self, backbone):
        super().__init__()
        #입력 줄기
        layer0 = nn.Sequential(backbone.conv1, backbone.bn1, backbone.relu)
        #네 개의 스테이지
        layer1 = backbone.layer1
        layer2 = backbone.layer2
        layer3 = backbone.layer3
        layer4 = backbone.layer4
				
				#세 번째 스테이지에서 분기를 나눠야 함: features로 정의
        self.features = nn.Sequential(layer0, layer1, layer2, layer3)
        #upsampling 계층으로 추출된 특징 맵의 차원 수를 늘림
        self.upsampling= nn.Sequential(
            nn.Conv2d(in_channels=256, out_channels=512, kernel_size=1),
            nn.ReLU(inplace=True),
        )
        #extra: ResNet-34의 마지막 계층에 연결하는 계층 블록
        #멀티 스케일 특징 맵을 추출하는 계층들로 구성됨
        self.extra = nn.ModuleList(
            [
		            #extra 계층의 첫 번째 구성요소는 ResNet의 네 번째 스테이지를 입력해 계층들을 연결
                nn.Sequential(
                    layer4,
                    nn.Conv2d(in_channels=512, out_channels=1024, kernel_size=1),
                    #ReLU 함수에 적용된 inplace 매개변수는 입력 텐서의 직접 수정 여부를 설정
                    #True: 입력 텐서를 직접 수정해 출력을 생성하지 않으므로 메모리 사용량이 줄어든다.
                    nn.ReLU(inplace=True),
                ),
                nn.Sequential(
                    nn.Conv2d(1024, 256, kernel_size=1),
                    nn.ReLU(inplace=True),
                    nn.Conv2d(256, 512, kernel_size=3, padding=1, stride=2),
                    nn.ReLU(inplace=True),
                ),
                 nn.Sequential(
                    nn.Conv2d(512, 128, kernel_size=1),
                    nn.ReLU(inplace=True),
                    nn.Conv2d(128, 256, kernel_size=3, padding=1, stride=2),
                    nn.ReLU(inplace=True),
                ),
                nn.Sequential(
                    nn.Conv2d(256, 128, kernel_size=1),
                    nn.ReLU(inplace=True),
                    nn.Conv2d(128, 256, kernel_size=3),
                    nn.ReLU(inplace=True),
                ),
                nn.Sequential(
                    nn.Conv2d(256, 128, kernel_size=1),
                    nn.ReLU(inplace=True),
                    nn.Conv2d(128, 256, kernel_size=3),
                    nn.ReLU(inplace=True),
                ),
                nn.Sequential(
                    nn.Conv2d(256, 128, kernel_size=1),
                    nn.ReLU(inplace=True),
                    nn.Conv2d(128, 256, kernel_size=4),
                    nn.ReLU(inplace=True),
                )
            ]
        )

#순전파 메서드
 def forward(self, x):
        x = self.features(x)
        #모델의 출력은 여러 계층에서 추출된 특징 맵이 포함됨
        output = [self.upsampling(x)]
				#extra 변수에 선언한 계층을 순차적으로 적용, output 변수에 누적
        for block in self.extra:
            x = block(x)
            output.append(x)
				#생성된 특징 맵은 순서를 보장하는 딕셔너리로 변환해 반환
				#클래스 분류 및 박스 회귀 네트워크에 전달
        return OrderedDict([(str(i), v) for i, v in enumerate(output)])
#SSD512 모델 생성
import torch
from torchvision.models import resnet34
from torchvision.models.detection import ssd
from torchvision.models.detection.anchor_utils import DefaultBoxGenerator

#특징 추출 모델: ResNet-34는 이미지넷 데이터세트로 사전 학습된 모델을 불러옴
backbone_base = resnet34(weights="ResNet34_Weights.IMAGENET1K_V1")
#SSDBackbone에 전달
backbone = SSDBackbone(backbone_base)
#SSDBackbone 클래스에서 생성된 모델의 출력 개수와 동일한 구조를 갖는 [기본 박스 생성자]에 전달
anchor_generator = DefaultBoxGenerator(
		#기본 박스 종횡비
		#각 위치에 생성될 기본 박스의 가로세로 비율을 설정
		#e.g. 첫 번째 값인 [2]는 가로세로 비율이 1:2인 기본 박스가 생성
		#e.g. 두 번째 값인 [2,3]은 가로세로 비율이 1:2인 기본 박스와 1:3인 기본 박스가 생성
    aspect_ratios=[[2], [2, 3], [2, 3], [2, 3], [2, 3], [2], [2]],
    #기본 박스 비율
    #생성할 기본 박스의 크기를 설정하며 리스트 내에 지정된 값이 클수록 더 큰 박스를 생성
    scales=[0.07, 0.15, 0.33, 0.51, 0.69, 0.87, 1.05, 1.20],
    #기본 박스 간격
    #기본 박스의 다운 샘플링 비율로 사용되며, 직사각형 격자(Meshgrid)를 만드는 데 사용됨
    steps=[8, 16, 32, 64, 100, 300, 512],
)
#예제의 백본 모델은 총 7개의 특징 맵(upsampling(1) + extra(6))을 반환한다.
#기본 박스의 종횡비와 간격은 입력 길이가 동일하게 7이 되어야 함

device = "cuda" if torch.cuda.is_available() else "cpu"
model = ssd.SSD(
    backbone=backbone,
    anchor_generator=anchor_generator,
    #입력 크기를 512, 512로 제한
    size=(512, 512),
    #클래스 개수를 3개로 설정
    num_classes=3
).to(device)
#출력 채널 할당 방법
#Faster R-CNN 모델에서는 출력 채널을 직접 할당해 설정
#SSD에서는 출력 채널 배열을 계산

#가상의 이미지를 입력해 특성 맵을 추출하고 각 계층의 출력 채널 수를 반환하는 함수
#백본에 대해 호출해 결과를 예상할 수 있음
def retrieve_out_channels(model, size):
    model.eval()
    #모델을 평가 모드로 변경
    with torch.no_grad():
        device = next(model.parameters()).device
        #가상의 이미지를 생성
        image = torch.zeros((1, 3, size[1], size[0]), device=device)
        #백본에 전달해 특징만 추출
        features = model(image)
        #기본 박스 생성자의 기본 박스 종횡비나 간격을 변경하는 경우 백본 모델의 구조도 변경해야 함
        #합성곱 계층은 커널 크기, 패딩, 간격 등으로 이미지의 크기를 점점 작게 만들므로 모델 매개변수를 예상하는 구조에 맞게 변경
        if isinstance(features, torch.Tensor):
            features = OrderedDict([("0", features)])
        out_channels = [x.size(1) for x in features.values()]

    model.train()
    return out_channels

print(retrieve_out_channels(backbone, (512, 512)))

이후 모델 학습 과정은 Faster R-CNN과 동일하다.



#데이터세트 클래스 선언
import os
import torch
from PIL import Image
from pycocotools.coco import COCO
from torch.utils.data import Dataset

class COCODataset(Dataset):
		#root: MS COCO 데이터세트의 경로
		#train: 학습 데이터세트 불러오기 여부: 거짓으로 하면 검증용 데이터세트
    def __init__(self, root, train, transform=None):
        super().__init__()
        directory = "train" if train else "val"
        #annotations: annotations 디렉터리에 있는 어노테이션 JSON 파일 경로 설정
        annotations = os.path.join(root, "annotations", f"{directory}_annotations.json")
        #이미지와 어노테이션 정보를 불러오기 전에 학습에 사용되는 카테고리 정보를 불러옴
        self.coco = COCO(annotations)
        self.iamge_path = os.path.join(root, directory)
        self.transform = transform
				#카테고리 정보를 불러오기
        self.categories = self._get_categories()
        #이미지와 어노테이션 정보를 불러오기
        self.data = self._load_data()
		#self.coco 인스턴스의 cats 속성에서 카테고리 정보를 불러옴
		#cats: 딕셔너리 구조: 상위 카테고리, 카테고리 ID, 카테고리 이름 포함
    def _get_categories(self):
		    #categories: 모델 추론 시 카테고리 정보를 확인하기 위해 사용
		    #0: 배경을 의미
        categories = {0: "background"}
        for category in self.coco.cats.values():
            categories[category["id"]] = category["name"]
        return categories
    
    #COCO 데이터세트 불러오기
    def _load_data(self):
        data = []
        #imgs 속성: 어노테이션 JSON 파일의 이미지 정보(images)를 순차적으로 반환
        #어노테이션 정보는 이미지 ID와 매핑될 수 있으므로 이미지 ID(_id)를 추출
        for _id in self.coco.imgs:
		        #입력된 이미지 ID를 받아 어노테이션 정보를 반환
		        #한 번에 여러 ID를 입력받을 수 있어 리스트 형식으로 반환
		        #현재 하나의 ID만 전달하므로 첫 번째 어노테이션 정보를 가져와 파일 이름을 추출하고 이미지를 불러옴
            file_name = self.coco.loadImgs(_id)[0]["file_name"]
            image_path = os.path.join(self.iamge_path, file_name)
            image = Image.open(image_path).convert("RGB")

            boxes = []
            labels = []
            #self.coco.loadAnns: 어노테이션 정보를 불러온다. (어노테이션 ID를 가져온다.)
            #self.coco.getAnnIds: 이미지 ID를 입력했을 때 어노테이션 ID를 반환한다.
            anns = self.coco.loadAnns(self.coco.getAnnIds(_id))
            #이미지 안에 여러 객체가 존재할 수 있으므로 다수의 어노테이션 정보가 포함될 수 있다.
            #반복문을 활용해 카테고리 ID와 경계 상자 정보를 추출한다.
            for ann in anns:
                x, y, w, h = ann["bbox"]
                #Faster R-CNN은 x(min),y(min),x(max),y(max)의 구조를 사용하므로 경계 상자 데이터 구조를 변경
                boxes.append([x, y, x + w, y + h])
                labels.append(ann["category_id"])
						#target 딕셔너리: 이미지 ID, 경계 상자, 레이블 저장
						#적합한 텐서 형식으로 변환
            target = {
            #이미지 ID: 모델 학습에는 사용되지 않지만, 모델 평가 과정에서 사용
            "image_id": torch.LongTensor([_id]),
                "boxes": torch.FloatTensor(boxes),
                "labels": torch.LongTensor(labels)
            }
            data.append([image, target])
        return data
		#호출 및 길이 반환 메서드
    def __getitem__(self, index):
        image, target = self.data[index]
        #호출 메서드는 이미지 변환이 적용될 수 있으므로 self.transform속성이 존재하면 변환을 적용
        if self.transform:
            image = self.transform(image)
        return image, target
		#저장한 데이터의 길이를 반환
    def __len__(self):
        return len(self.data)
#데이터로더
from torchvision import transforms
from torch.utils.data import DataLoader

def collator(batch):
    return tuple(zip(*batch))

transform = transforms.Compose(
    [
		    #PIL 이미지를 텐서로 변환
        transforms.PILToTensor(),
        #텐서 이미지를 다시 float 형식으로 변환
        #모델이 float 형식의 [0.0, 1.0] 범위를 갖는 이미지 텐서를 사용하기 때문
        transforms.ConvertImageDtype(dtype=torch.float)
    ]
)

train_dataset = COCODataset("../datasets/coco", train=True, transform=transform)
test_dataset = COCODataset("../datasets/coco", train=False, transform=transform)
#COCO 데이터세트는 이미지 내에 여러 객체 정보가 담길 수 있으므로 데이터의 길이가 다를 수 있다.
#집합 함수(collate_fn)를 적용해 데이터를 패딩한다.
#collator: 데이터로더에 데이터 패딩을 적용한다.
train_dataloader = DataLoader(
    train_dataset, batch_size=4, shuffle=True, drop_last=True, collate_fn=collator
)
test_dataloader = DataLoader(
    test_dataset, batch_size=1, shuffle=True, drop_last=True, collate_fn=collator
)
#최적화 함수 및 학습률 스케줄러
from torch import optim

#학습이 가능한 매개변수만 params 변수에 저장해 확률적 경사 하강법을 적용
params = [p for p in model.parameters() if p.requires_grad]
#학습률 = 0.001, 모멘텀 = 0.9, 가중치 감쇠 = 0.0005
optimizer = optim.SGD(params, lr=0.001, momentum=0.9, weight_decay=0.0005)
#학습률 스케줄러: 지정된 주기마다 학습률을 감소시킴
#5 에폭마다 학습률이 0.1씩 줄어든다.
lr_scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=5, gamma=0.1)
#[?]스케줄러도 step 메서드로 학습률을 갱신 가능: 한 에폭이 완료된 후에 호출[?]
#모델 미세 조정
for epoch in range(5):
    cost = 0.0
    for idx, (images, targets) in enumerate(train_dataloader):
		    #배치 크기로 데이터가 묶여 있으므로 리스트 간소화를 통해 장치 설정
        images = list(image.to(device) for image in images)
        #targets 변수는 딕셔너리 이므로 딕셔너리 간소화를 통해 적용
        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, cost:      6.242
Epoch:      2, cost:      5.322
...
Epoch:      9, cost:      3.126
Epoch:     10, cost:      3.032
#모델 추론 및 시각화
import numpy as np
from PIL import Image
from matplotlib import pyplot as plt
from torchvision.transforms.functional import to_pil_image

#Pillow 라이브러리로 사각형과 텍스트를 이미지 위에 그리는 함수
def draw_bbox(ax, box, text, color):
    ax.add_patch(
        plt.Rectangle(
            xy=(box[0], box[1]),
            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,
    )

#임곗값을 0.5로 설정해 50% 이상의 객체만 표시한다.
threshold = 0.5
categories = test_dataset.categories
with torch.no_grad():
    model.eval()
    for images, targets in test_dataloader:
        images = [image.to(device) for image in images]
        outputs = model(images)
        
        boxes = outputs[0]["boxes"].to("cpu").numpy()
        labels = outputs[0]["labels"].to("cpu").numpy()
        scores = outputs[0]["scores"].to("cpu").numpy()
        
        boxes = boxes[scores >= threshold].astype(np.int32)
        labels = labels[scores >= threshold]
        scores = scores[scores >= threshold]

        fig = plt.figure(figsize=(8, 8))
        ax = fig.add_subplot(1, 1, 1)
        plt.imshow(to_pil_image(images[0]))

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

        tboxes = targets[0]["boxes"].numpy()
        tlabels = targets[0]["labels"].numpy()
        for box, label in zip(tboxes, tlabels):
            draw_bbox(ax, box, f"{categories[label]}", "blue")
            
        plt.show()
import numpy as np
from pycocotools.cocoeval import COCOeval #COCO 데이터셋 평가

with torch.no_grad():
		#평가 모드
    model.eval()
    coco_detections = []
    #test_dataloader로부터 이미지와 타겟을 가져옴
    for images, targets in test_dataloader:
		    #이미지를 GPU(CPU)로 이동
        images = [img.to(device) for img in images]
        #모델을 통해 출력을 얻음
        outputs = model(images)
				
				#각 이미지 타겟에 대해 COCO 형식의 결과 생성
				#박스 좌표 조정, 점수 및 라벨 가져옴
        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]
            boxes[:, 3] = boxes[:, 3] - boxes[:, 1]
            scores = outputs[i]["scores"].data.cpu().numpy()
            labels = outputs[i]["labels"].data.cpu().numpy()
				
				#검출 결과를 COCO 형식에 맞게 변환: coco_detections리스트에 추가
        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 데이터셋 형식으로 변환
    coco_detections = np.asarray(coco_detections)

    coco_gt = test_dataloader.dataset.coco
    coco_dt = coco_gt.loadRes(coco_detections)
    #COCO 평가자 객체를 통해 검출 결과를 평가하여 성능 요약
    #IoU를 활용하여 박스의 겹침 정도를 평가
    coco_evaluator = COCOeval(coco_gt, coco_dt, iouType="bbox")
    coco_evaluator.evaluate()
    coco_evaluator.accumulate()
    coco_evaluator.summarize()

SSD에서 반환되는 손실값은 박스 회귀 손실(bbox_regression)과 객체 분류 손실(classification)이다.

손실값은 딕셔너리 구조로 생성된다.

손실 함수 Total Loss =  Confidence Loss(class 분류 손실 값 : 정확성) + Localization Loss(bounding box 예측 손실 값 : 정밀성)

img1.daumcdn.png