본문 바로가기

책/파이토치 트랜스포머를 활용한 자연어 처리와 컴퓨터비전 심층학습

09 객체 탐지 (4) FCN

FCN(Fully Convolutional Network)는 이미지 분류에서 우수한 성능을 보인 CNN 기반 모델을 Sementic Segmentation 작업을 수행할 수 있도록 변형시킨 모델이다.

 

Sementic segmentation은 이미지를 픽셀 수준에서 이해하고 해석하는 작업을 말한다. 이미지에서 각 픽셀을 특정 객체 또는 클래스에 할당하여 이미지 내의 각 객체의 영역을 식별하는 기술이다. 

 

전통적인 합성곱 신경망은 완전 연결 계층을 사용하기 때문에 픽셀의 위치 정보를 파악하기 어려운데, FCN은 이를 1x1 합성곱으로 대체한다. 또한 특징 맵의 크기를 줄여가며 이미지의 정보를 추출하기 때문에 업샘플링 기법을 적용해 특징 맵을 입력 크기와 동일하게 확장하는 과정을 추가한다.

 

 

1. Upsampling

1.1 이중 선형 보간법

이중 선형 보간법(Bilinear Intepolation)은 선형 보간법(Linear Interpolation)을 여러 번 적용해 구할 수 있다.

 

선형 보간법

 

선형 보간법은 두 점 사이의 값을 추정할 때 추정할 값과 두 점 사이의 직선 거리에 따라 선형적으로 결정하는 방법이다.

 

두 점 $x_1$, $x_2$과 해당 지점에서의 값이 $f(x_1)$, $f(x_2)$일 때, $x_1 \le x \le x_2$를 만족하는 $x$에서의 값 $f(x)$는 다음과 같이 계산된다.

 

 

 

이제 이중 선형 보간법을 보자.

이중 선형 보간법

 

알려진 점 A, B, C, D를 이용해 점 P에서의 값을 보간한다. 먼저 점 A, B를 선형 보간해 점 M을 구하고, 점 C, D를 선형 보간해 점 N을 구한다. 그런 다음 점 M, N을 선형 보간해 점 P의 값을 추정한다. M, N을 추정한 뒤 P를 구하는 방법 말고 V, U를 사용해 P를 추정하는 것도 가능하다.

 

수식은 다음과 같다.

 

 

1.2 전치 합성곱

전치 합성곱은 입력에 패딩을 넣어 입력의 크기보다 큰 출력값을 생성하는 합성곱 연산이다.

 

 

전치 합성곱은 학습이 가능하기 때문에 일반적인 보간법보다 더 나은 성능을 보일 수 있다. 하지만 입력의 크기가 클수록 연산량이 기하급수적으로 증가하기 때문에 최근에는 주로 사용되지 않는 경향이 있으며, 업샘플링에 대한 최적의 방법을 찾기 위해 많은 연산이 필요하므로 높은 계산 비용을 요구한다.

 

 

2. 모델 구조

FCN architecture

 

위의 그림처럼 입력 이미지의 크기가 192x192일 때의 기준으로 설명한다.

FCN-32s

  • conv5까지 통과해 32배 줄어든 feature map5 (6x6)
  • feature map5에 3개의 합성곱 Conv2d(1x1, in=512, out=4096), Conv2d(1x1, in=4096, out=4096), Conv2d(1x1, in=4096, out=클래스 개수)을 적용시켜 feature map6 (6x6)을 만든다.
  • feature map6에 이중 선형 보간법을 사용해 32배 업샘플링을 수행한다.
  • 업샘플링 결과는 (192x192x클래스개수)이다.

 

FCN-16s

  • conv4까지 통과해 16배 줄어든 feature map4 (12x12)
  • 32와 동일한 방법으로 feature map6을 만든 다음 feature map6에 전치 합성곱을 적용하여 2배 업샘플링을 수행한다.
  • 크기가 동일한 feature map4와 6을 더해 새로운 feature map 4-1 (12x12)을 만든다.
  • feature map4-1에 이중 선형 보간법을 사용해 16배 업샘플링을 수행한다.
  • 업샘플링 결과는 (192x192x클래스개수)이다.

 

FCN-8s

  • conv3까지 통과해 8번 줄어든 feature map3 (24x24)와 feature map4-1을 2배 업샘플링한 feature map 4-2 (24x24)을 더하여 feature map 3-1 (24x24)을 만든다.
  • feature map 3-1을 이중 선형 보간법을 사용해 8배 업샘플링을 수행한다.
  • 업샘플링 결과는 (192x192x클래스개수)이다.

 

 

결과를 보면 32 -> 16 -> 8로 갈수록 정교해진다.

 

 

3. 모델 실습

파스칼 VOC 2012 챌린지 데이터셋을 사용해 FCN 모델을 미세 조정한다. 데이터셋에는 1,464장의 학습 이미지와 1,449장의 검증 이미지를 가지고 있고 레이블은 20개의 객체 클래스와 배경 클래스로 구성된다.

 

여기서는 책에서 제공하는 데이터셋을 다운받아 사용한다.

 

데이터셋 구조는 다음과 같다.

Folder: VOCdevkit
  L Folder: VOC2012
    L Folder: ImageSets
      L Folder: Segmentation
        file: trainval.txt
        file: train.txt
        file: val.txt
      L Folder: Main
        file: person_train.txt
        file: sofa_trainval.txt
        file: car_train.txt
      L Folder: Action
        file: reading_val.txt
        file: ridingbike_train.txt
        file: usingcomputer_val.txt
      L Folder: Layout
        file: trainval.txt
        file: train.txt
        file: val.txt
    L Folder: SegmentationClass
      file: 2008_000144.png
      file: 2010_000269.png
      file: 2009_004324.png
    L Folder: JPEGImages
      file: 2008_000254.jpg
      file: 2007_004380.jpg
      file: 2011_001005.jpg
    L Folder: SegmentationObject
      file: 2008_000144.png
      file: 2010_000269.png
      file: 2009_004324.png
    L Folder: Annotations
      file: 2007_003778.xml
      file: 2008_008521.xml
      file: 2011_001014.xml
    file: classes.json

 

데이터셋은 크게 ImageSets(특정 task 수행을 위한 파일 정보가 담긴 txt 파일), SegmentationClass(sementic segmentation 이미지), JPEGImages(원본 이미지), SegmentationObject(인스턴스 segmentation 이미지), Annotations 폴더로 나뉘어져 있다.

 

우리는 segmentation 모델을 구성할 예정이므로 JPEGImages와 SegmentationClass에 담겨있는 원본 이미지와 마스크 이미지를 사용하며, ImageSets의 Segmentation의 train.txt와 val.txt를 활용해 이미지를 불러온다. txt 파일은 이미지 파일명이 라인별로 저장돼 있다.

 

classes.json에는 21개(객체 20, 배경 1)클래스에 대한 색상 코드가 정리된 파일이다.

 

 

데이터셋

class SegmentationDataset(Dataset):
  def __init__(self, root, train, transform=None, target_transform=None):
    super().__init__()

    self.root = os.path.join(root, "VOCdevkit", "VOC2012")
    file_type = "train" if train else "val"
    file_path = os.path.join(
        self.root, "ImageSets", "Segmentation", f"{file_type}.txt"
    )
    with open(os.path.join(self.root, "classes.json"), "r") as file:
      self.categories = json.load(file)
    self.files = open(file_path).read().splitlines() # 파일명 리스트 ex) 2007_000121
    self.transform = transform
    self.target_transform = target_transform
    self.data = self._load_data()
  
  def _load_data(self):
    data = []
    for file in self.files:
      image_path = os.path.join(self.root, "JPEGImages", f"{file}.jpg")
      mask_path = os.path.join(self.root, "SegmentationClass", f"{file}.png")
      image = Image.open(image_path).convert("RGB")
      mask = np.array(Image.open(mask_path))
      mask = np.where(mask == 255, 0, mask)
      target = torch.LongTensor(mask).unsqueeze(0)
      data.append([image, target])
    return data
  
  def __getitem__(self, index):
    image, mask = self.data[index]
    if self.transform is not None:
      image = self.transform(image)
    if self.target_transform is not None:
      mask = self.target_transform(mask)
    return image, mask
  
  def __len__(self):
    return len(self.data)

 

 

데이터로더

transform = transforms.Compose(
    [
        transforms.PILToTensor(),
        transforms.ConvertImageDtype(torch.float),
        transforms.Resize(size=(224, 224))
    ]
)
target_transform = transforms.Compose(
    [
        transforms.Resize(
            size=(224, 224),
            interpolation=transforms.InterpolationMode.NEAREST
        )
    ]
)

train_dataset = SegmentationDataset(
    root=DATA_PATH / "datasets",
    train=True,
    transform=transform,
    target_transform=target_transform
)

val_dataset = SegmentationDataset(
    root=DATA_PATH / "datasets",
    train=False,
    transform=transform,
    target_transform=target_transform
)

train_dataloader = DataLoader(
    train_dataset,
    batch_size=8,
    shuffle=True,
    drop_last=True
)

val_dataloader = DataLoader(
    val_dataset,
    batch_size=4,
    shuffle=True,
    drop_last=True
)

 

 

데이터 시각화

  • color_mask 함수는 마스크 이미지의 픽셀을 색상으로 매핑하는 과정을 수행한다.
  • target의 픽셀 값은 0~20으로 구성되며, 각 픽셀값이 클래스를 의미한다.
def draw_mask(images, masks, outputs=None, plot_size=4):
  def color_mask(image, target):
    m = target.squeeze().numpy().astype(np.uint8)
    cm = np.zeros_like(image, dtype=np.uint8)

    for i in range(1, 21):
      cm[m == i] = train_dataset.categories[str(i)]["color"]
    
    classes = [train_dataset.categories[str(idx)]["class"] for idx in np.unique(m)]
    return cm, classes
  
  col = 3 if outputs is not None else 2
  figsize = 20 if outputs is not None else 28
  fig, ax = plt.subplots(plot_size, col, figsize=(14, figsize), constrained_layout=True)

  for batch in range(plot_size):
    im = images[batch].numpy().transpose(1, 2, 0)
    ax[batch][0].imshow(im)
    ax[batch][0].axis("off")

    cm, classes = color_mask(im, masks[batch])
    ax[batch][1].imshow(cm)
    ax[batch][1].axis("off")
    ax[batch][1].set_title(f"{classes}")

    if outputs is not None:
      cm, classes = color_mask(im, outputs[batch])
      ax[batch][2].set_title(f"{classes}")
      ax[batch][2].imshow(cm)
      ax[batch][2].axis("off")

images, masks = next(iter(train_dataloader))
draw_mask(images, masks)

 

 

학습

  • torchvision에서 제공하는 FCN 모델은 전치 합성곱을 사용하지 않고 이중 선형 보간법으로 한 번에 업샘플링하는 방식으로 제공된다.
  • 해당 모델은 MS COO의 하위 집합 파스킬 VOC 데이터셋에 존재하는 20개의 카테고리만 사용해 학습된 모델이며, 520x520 크기로 사전 학습됐다.
num_classes = 21
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = models.segmentation.fcn_resnet50(
    weight="FCN_ResNet50_Weights.COCO_WITH_VOC_LABELS_V1",
    num_classes=21
).to(device)

params = [p for p in model.parameters() if p.requires_grad]
optimizer = torch.optim.SGD(
    params,
    lr=1e-3,
    momentum=0.9,
    weight_decay=5e-4
)
criterion = torch.nn.CrossEntropyLoss()
from tqdm import tqdm

for epoch in range(30):
  model.train()
  cost = 0.0

  for images, targets in tqdm(train_dataloader, desc=f'Epoch {epoch}', leave=False):
    images = images.to(device)
    targets = targets.to(device)

    outputs = model(images)
    outputs = outputs["out"].permute(0, 2, 3, 1).contiguous().view(-1, num_classes) # batch_size, height, width, num_classes로 변경
    targets = targets.permute(0, 2, 3, 1).contiguous().view(-1)

    loss = criterion(outputs, targets)

    optimizer.zero_grad()
    loss.backward()
    optimizer.step()

    cost += loss.item()
  
  cost /= len(train_dataloader)
  print(f"Epoch {epoch} cost: {cost}")
Epoch 0 cost: 0.7913992481479228
Epoch 1 cost: 0.45463336393481396
Epoch 2 cost: 0.3802527468549749
...
Epoch 27 cost: 0.11541939225516033
Epoch 28 cost: 0.1125810641408618
Epoch 29 cost: 0.11089149966347413

 

 

추론

with torch.no_grad():
  model.eval()
  images, masks = next(iter(val_dataloader))
  outputs = model(images.to(device))["out"]
  outputs = outputs.argmax(axis=1).to("cpu")
  draw_mask(images, masks, outputs, 4)

 

 

평가

mIoU(Mean Intersection over Union) 지표로 모델을 평가한다. 이는 segmentation 모델을 평가하기 위한 지표로써, 픽셀별로 예측된 세그멘테이션과 실제 세그멘테이션 간의 교차영역을 교집합으로 나눈 값을 모든 클래스에 대해 평균화한 값이다.

 

수식으로는 다음과 같이 정의된다.

N : 클래스 개수

${TP}_i$ : 클래스 i에 대한 정확한 예측 픽셀 수

${FP}_i$ : 클래스 i에 대한 잘못된 예측 픽셀 수

${FN}_i$ : 클래스 i에 대한 누락된 픽셀 수

 

calculate_iou 함수에서 intersection은 TP로 예측과 타겟의 교집합을 계산한다. union은 예측과 타겟 중 하나라도 속한 픽셀 수를 계산하며 FP + FN을 나타낸다. class_count는 클래스별로 IoU를 계산한 횟수를 누적하여 나중에 각 클래스의 IoU 평균을 계산할 때 사용된다.

 

from collections import defaultdict


def calculate_iou(targets, outputs, ious, class_count, num_classes=21):
    for i in range(num_classes):
        intersection = np.float32(np.sum((outputs == targets) * (targets == i)))
        union = np.sum(targets == i) + np.sum(outputs == i) - intersection
        if union > 0:
            ious[i] += intersection / union
            class_count[i] += 1
    return ious, class_count


ious = np.zeros(21)
class_count = defaultdict(int)
with torch.no_grad():
    model.eval()
    for images, targets in val_dataloader:
        images = images.to(device)
        outputs = model(images)["out"].permute(0, 2, 3, 1).detach().to("cpu").numpy()
        targets = targets.permute(0, 2, 3, 1).squeeze().detach().to("cpu").numpy()
        outputs = outputs.argmax(-1)

        ious, class_count = calculate_iou(targets, outputs, ious, class_count, 21)

miou = 0.0
for idx in range(1, 21):
    miou += ious[idx] / class_count[idx]
miou /= 20
print(f"mIoU 계산 결과 : {miou}")
mIoU 계산 결과 : 0.33712685013987215

 

예측과 타겟 사이의 겹치는 영역이 전체 영역 중 33%를 차지한다. 매우 적은 개수의 데이터와 224로 크기가 작은 입력으로 학습했음에도 불구하고 비교적 준수한 성능을 보인다.