| 모델 명 | 특징 |
|---|---|
| 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)))
#데이터세트 클래스 선언
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 예측 손실 값 : 정밀성)
