greatsangho의 이야기

캠프59일차 - 3. 합성곱(CNN) 신경망(CNN 개요, 합성곱 계층, 풀링 계층) U-Net 본문

프로그래밍/SK AI 캠프

캠프59일차 - 3. 합성곱(CNN) 신경망(CNN 개요, 합성곱 계층, 풀링 계층) U-Net

greatsangho 2024. 11. 19. 19:57

https://www.robots.ox.ac.uk/~vgg/data/pets/

 

Visual Geometry Group - University of Oxford

 

www.robots.ox.ac.uk

이 데이터는 강아지와 고양이를 구분하기 위한 데이터 셋으로 원본 이미지와 동물을 동물 자체와 테두리로 구분하여 표현한 형태로 이루어져 있다. 일반 이미지는 jpg, annotation 이미지는 png로 이루어져 있으므로 glob을 통해 해당 형식으로 불러온다.

 

U-NET : 이미지 분할을 위한 CNN 모델로 세밀한 객체 경계를 분할한다.
  합성곱층 ----원래의 특징과 복원된 특징을 합침-------- > 합성곱층
  합성곱층에서 특징추출 : 업샘플링층
  업샘플링층에서 특징 복원 : 합성곱
  업샘플링층 : 이미지에서 추출한 특징을 이용해서 이미지를 복원하는 과정

이와 같은 구조로 이루어진 모델이다.

U-Net의 구조 [ arXiv:1505.04597 ]

이 이미지는 32 x 32 픽셀을 가장 낮은 resolution으로 가지는 U-Net 구조이다. 파란색 박스는 multi-channel feature map이다.

인코더 (Contracting Path)

각 단계에서 3x3 Conv + ReLU 연산이 두번 수행된다. 2x2 Max Pooling으로 이미지를 절반으로 줄이고 채널을 2배로 늘린다. 이 과정에서 공간 정보를 손실하는 대신 특징을 학습한다.

 

디코더 (Expanding Path)

수축 경로에서 얻은 특징을 사용하여 이미지를 다시 복원함. 각 단계별로 업샘플링(Up-sampling, 초록 화살표)이 진행되며 인코더에서 저장한 특집 맵을 연결하는 스킵 연결이 이루어진다(회색 화살표). 2x2 Up-Convolution(업샘플링) 과정에서 채널 수는 절반으로 줄어들게 된다.

 

스킵 연결 (Skip Connections)

스킵 연결은 인코딩 과정에서 추출된 고해상도 정보를 대응되는 디코딩 과정으로 전달하는 연결로, 저해상도 정보와 고해상도 정보를 결합하여 더 정확한 분석이 가능하도록 한다.

 

특징

대칭적인 구조를 가지고, 스킵 연결을 통해 세부적인 정보를 가지면서 분할을 할 수 있다.

입력 이미지와 동일한 크기의 출력으로 픽셀 단위로 예측이 가능하다.

작은 데이터셋을 사용해도 데이터 증강 기법 및 스킵 연결로 높은 성능이 가능하다.

주로 의료 영상 분석 등에 사용된다.

 

from torch.utils.data.dataset import Dataset
from PIL import Image
class PetsDataset(Dataset):
  def __init__(self, input_img_paths, target_img_paths,transforms = None, input_size=(128,128),train=True):
    self.input_img_paths = sorted(glob(input_img_paths+'/*.jpg'))
    self.target_img_paths = sorted(glob(target_img_paths+'/*.png'))

    self.transforms = transforms
    self.input_size = input_size
    self.train = train

    # 평가용, 학습용  8:2
    ...
    
  # 정답 마스크 변환
  def preprocess_mask(self, mask):
    mask = mask.resize(self.input_size)
    mask =  np.array(mask).astype(np.float32)
    mask[mask != 2] = 1.0
    mask[mask == 2] = 0.0
    mask = torch.tensor(mask)
    return mask
    
  def __len__(self):
    if self.train:
      return len(self.x_train)
    else:
      return len(self.x_test)
      
  def __getitem__(self, index):
    if self.train:
      x_train =  Image.open(self.x_train[index]).convert('RGB')
      x_train = self.transforms(x_train)
      y_train = Image.open(self.y_train[index])
      y_train = self.preprocess_mask(y_train)
      return x_train, y_train
    else:
      ...

데이터 셋을 불러오는 클래스를 선언한다. 기본적인 def __init__(), preprocess_mask(), __len__(self), __getitem__(self, index)를 정의한다.

__init__()

데이터셋을 불러온 다음 데이터셋을 train과 test로 나눈다.

preprocess_mask()

mask는 0,1,2로 이루어져 있으며, 객체, 테두리, 배경에 해당한다. 여기서 배경인 2를 제외한 나머지는 1을 할당하고, 나머지에 배경은 0을 할당한다.

__len__(self)

x_train과 x_test의 길이를 반환한다.

__getitem__(self, index)

인덱스에 해단하는 이미지를 불러온다. 이미지 데이터의 차원을 맞추어 주기 위해 x_train과 x_test는 convert('RGB')를 해준다.

 

# 인코더
# [합성곱+합성곱+폴링층] x 5 + ReLU
# 디코더
# [transporse2d + 합성곱 + 합성곱] x 4

의 구조로 이루어져 있다. 파이토치로 구현하면

  # 순전파
  def forward(self, x):
    # 인코딩 부분
    x = self.enc1_1(x)
    x = self.relu(x)
    e1 = self.enc1_2(x) # 디코더에서 사용하기 위하여 변수 따로 지정
    e1 = self.relu(e1)
    x = self.pool1(e1)
    
    ...
    
    x = self.enc5_1(x)
    x = self.relu(x)
    x = self.enc5_2(x)
    x = self.relu(x)

인코딩은 다음과 같이 정의되며,

    x = self.upsample4(x)
    x = torch.cat([x, e4], dim=1)
    x = self.dec4_1(x)
    x = self.relu(x)
    x = self.dec4_2(x)
    x = self.relu(x)
    
    ...
    
    
    x = self.upsample2(x)
    x = torch.cat([x, e2], dim=1)
    x = self.dec2_1(x)
    x = self.relu(x)
    x = self.dec2_2(x)
    x = self.relu(x)

    x = self.upsample1(x)
    x = torch.cat([x, e1], dim=1)
    x = self.dec1_1(x)
    x = self.relu(x)
    x = self.dec1_2(x)
    x = self.relu(x)
    x = self.dec1_3(x)
    
    x = torch.squeeze(x) # 흑백으로 나타내기 위해 채널 없애기
    return x

디코딩 과정은 upsample이 이루어지고, 스킵 커넥션이 cat을 통해 이루어진다.

device = 'cuda' if torch.cuda.is_available() else 'cpu'
print('device')
# 데이터 전처리 정의하기
transform = Compose([Resize((128,128)), ToTensor()])

train_set = PetsDataset(input_img_paths = input_pth,
                        target_img_paths = output_pth,
                        transforms = transform)

test_set = PetsDataset(input_img_paths = input_pth,
                        target_img_paths = output_pth,
                        transforms = transform,
                        train = False)

train_loader = DataLoader(train_set, batch_size=16, shuffle=True)
test_loader = DataLoader(test_set)

다음과 같이 데이터를 전처리하며,

model = UNet().to(device)
learning_rate = 0.0001
optim = Adam(params = model.parameters(), lr=learning_rate)
loss_fn = nn.BCEWithLogitsLoss()
best_loss = float("inf")

for epoch in range(20): # 200
  iterator = tqdm.tqdm(train_loader)

  for data, label in iterator:
    optim.zero_grad()
    preds = model(data.to(device))
    loss = nn.BCEWithLogitsLoss()(
        preds,
        label.type(torch.FloatTensor).to(device),)
    loss.backward()
    optim.step()

    iterator.set_description(f'epoch:{epoch+1} loss:{loss.item()}')

  if loss.item() < best_loss:
    best_loss = loss.item()
    torch.save(model.state_dict(), '/content/drive/MyDrive/models/Unet.pth') # 저장 방지용 주석처리
    print(f"Best model saved at epoch {epoch+1} with loss: {loss.item()}")

옵치마이저, loss function, 모델도 마찬가지로 정의해준다. Loss가 기존 loss보다 작을 때 저장하도록 설정하였다.

와 같이 학습이 진행된다. 모델을 다시 불러와 그리면 다음과 같다.

import matplotlib.pyplot as plt

model.load_state_dict(torch.load('/content/drive/MyDrive/models/Unet.pth', map_location=torch.device('cpu')))
data, label = test_set[300]
pred = model(torch.unsqueeze(data.to(device), dim=0))>0.5  # 픽셀을 이진 분류함
pred = pred.cpu().detach().numpy()
label = label.cpu().detach().numpy()

모델의 예측, 정답 라벨링, 해당하는 원본 이미지

이와 같이 잘 학습되는 것을 확인할 수 있다.

 

멀티모달
- gpt4 api를 이용하면 쉽게
- 기존 이미지모델 + 텍스트데이터(음성 기타 등등)

U-Net을 활용하여 이미지와 이미지를 설명하는 텍스트를 입력하여 평가하는 멀티모달을 만들어본다.

1. 텍스트 입력 처리
  - 입력벡터로 변환 torch.nn.Embedding, BERT, GPT
2. 멀티모달 데이터 결합
  - 각각을 1:1로 결합
3. 학습(적당한 손실계산)
  - 결합된 데이터를 통해 이미지 세그먼테이션 수행
  - 이미지, 텍스트의 멀티모달 데이터 기반 손실을 계산
4. 추론 및 평가

 

class TextEmbedding(nn.Module):
  def __init__(self,vocab_size, embed_dim):
    super(TextEmbedding, self).__init__()
    self.embedding = nn.Embedding(vocab_size, embed_dim)
  def forward(self, x):
    return self.embedding(x)

TextEmbedding 클래스에서 텍스트를 임베팅 벡터로 변환하도록 nn.Embedding을 사용한다. 이를 통해 텍스트 토큰을 고정된 크기의 임베팅 벡터로 변환할 수 있다.

임베팅 벡터란 고차원 데이터를 저차원 벡터 공간에 표현한 것

class MultiModalUNet(nn.Module):
  def __init__(self, vocab_size, embed_dim,image_feature_dim):
    super(MultiModalUNet, self).__init__()
    self.text_embedding = TextEmbedding(vocab_size, embed_dim)
    self.unet = UNet()  # U-Net 구조
    self.fc1 = nn.Linear(image_feature_dim+embed_dim, 128)
    self.fc2 = nn.Linear(128, 1)

  def forward(self, text, image):
    text_embed = self.text_embedding(text)
    text_embed = torch.mean(text_embed, dim=1)  # 시퀀스 평균
    unet_output = self.unet(image)  # U-Net을 통해 이미지 처리
    unet_output = torch.flatten(unet_output, start_dim=1)  # 평탄화
    combined = torch.cat([text_embed, unet_output], dim=1)  # 텍스트와 이미지 결합
    x = self.fc1(combined)
    x = torch.relu(x)
    output = self.fc2(x)
    return output

멀티 모달 U-Net 모델을 선언한다. 해당 클래스는 텍스트 임배딩과 U-Net 기반 이미지 세그멘테이션을 결합한 모델이다.

세그멘테이션이란 이미지 처리 및 컴퓨터 비전에서 이미지를 여러 부분 또는 객체로 나누는 과정을 의미한다.

 

GPT 답변은 다음고 같다.

세그멘테이션의 주요 유형
Semantic Segmentation (의미론적 분할):
이미지 내의 각 픽셀을 특정 클래스(예: 사람, 자동차, 나무 등)에 할당합니다. 같은 클래스에 속하는 모든 객체는 동일하게 처리됩니다.
예시: 도로 사진에서 도로, 차량, 보행자 등을 각각 다른 클래스로 분류.
Instance Segmentation (인스턴스 분할):
같은 클래스에 속하는 객체라도 서로 다른 인스턴스로 구분합니다. 즉, 개별 객체를 분리하는 작업입니다.
예시: 여러 대의 차량이 있을 때 각 차량을 개별적으로 분리.

 

세그멘테이션 기법
경계 기반 세그멘테이션 (Edge-based Segmentation):
  이미지에서 경계를 찾아 객체를 분리하는 방법입니다. 주로 밝기나 색상의 변화가 큰 부분을 경계로 인식합니다.
영역 기반 세그멘테이션 (Region-based Segmentation):
  유사한 특성을 가진 픽셀들을 그룹화하여 영역을 형성하는 방식입니다.
신경망 기반 세그멘테이션 (Neural Network-based Segmentation):
  딥러닝 모델(CNN 등)을 사용하여 이미지를 분할합니다. 특히 의미론적 분할과 인스턴스 분할 작업에 많이 사용됩니다

 

text_embedding에서 위에서 정의한 TextEmbedding을 이용해 텍스트를 임베딩 벡터로 변환한다.

unet은 위에서 정의한 U-Net 구조를 가져와 사용한다. fc1과 fc2는

    # 텍스트와 이미지 피처 결합
    self.fc1 = nn.Linear(image_feature_dim+embed_dim, 128)  # 이미지 벡터와 텍스트 벡터를 결합
    self.fc2 = nn.Linear(128, 1)  # 이진 분류

이와 같이 임베팅과 이미지 특징을 결합한 후 이진 분류를 하기 위한 완전 연결층이다.

class MultiModalDataset(Dataset):
  def __init__(self, train_images, train_annotations, text, tokenizer, transforms=None,input_size=(128, 128)):
    self.images = train_images
    self.annotations = train_annotations
    self.text = text
    self.transforms = transforms
    self.input_size = input_size
    self.tokenizer = tokenizer

  def preprocess_mask(self, mask):
    mask = mask.resize(self.input_size)
    mask = np.array(mask).astype(np.float32)
    mask[mask != 2.0] = 1.0
    mask[mask == 2.0] = 0.0
    return torch.tensor(mask)

  def __getitem__(self, i):
    X_train = Image.open(self.images[i])
    if self.transforms:
      X_train = self.transforms(X_train)
    Y_train = Image.open(self.annotations[i])
    Y_train = self.preprocess_mask(Y_train)
    
    text = self.text[i]
    text = self.tokenizer(text, padding='max_length', truncation=True, return_tensors="pt", max_length=128)

    return X_train, Y_train, text

이미지와 마스크, 그리고 설명을 포함하는 데이터셋을 정의한다. preprocess_mask에서 배경과 객체로 나누어 이진분류를 시행한다. __getitem__은 이미지와 마스크, 그리고 전처리 및 토크나이징 된 데이터를 반환한다.

transforms = Compose([
   Resize((128, 128)),
   Grayscale(num_output_channels=3),
   ToTensor()
])
tokenizer = BertTokenizer.from_pretrained('bert-base-multilingual-cased')
train_set = MultiModalDataset(train_images=train_images,
                           train_annotations=train_annotations,
                           text=train_text,
                           tokenizer=tokenizer,
                           transforms=transforms)
train_loader = DataLoader(train_set, batch_size=2, shuffle=True)

트랜스 포머를 정의한다. 이미지가 Resize 및 Grayscale로 전처리되며, ToTensor로 파이토치로 변환한다. 사용하는 모델은 다국어를 지원하는 BERT 모델인 'bert-base-multilingual-cased'을 사용하였다.

for epoch in range(10):
  iterator = tqdm(train_loader)
  for i in iterator:
    optimizer.zero_grad()
    
    image, mask, text = i
    image = image.to(device)
    mask = mask.to(device)
    
    text_input_ids = text['input_ids'].to(device).squeeze(dim=1)
    
    output = model(text_input_ids, image)

    mask = mask.view(mask.shape[0], -1).mean(dim=1, keepdim=True)  
    
    loss = criterion(output, mask)
    
    loss.backward()
    optimizer.step()

모델의 출력을 계산하고 손실(' BCEWithLogitsLoss ')을 구하고, 역전파를 통해 가중치 업데이트를 진행한다.

BCEWithLogitsLoss

이진 분류(Binary Classification) 및 다중 레이블 분류(Multi-label Classification)에서 사용되며, Sigmoid 함수와 Binary Cross Entropy Loss (BCELoss)를 결합하여 하나의 함수로 처리한다. 이를 통해 logit 값 출력을 확률로 변환하고, 이를 Binary Cross Entropy 방식으로 계산하여 수치적으로 안정적이다. pos_weight로 양성 클래스에 가중치를 부여하여 클래스 불균형을 줄일 수 있다. 양성 클래스란 분류 문제에서 탐지나 예측하려는 대상을 의미한다. 예를 틀어 스팸 필터링에서 스팸 메일이 양성 클래스, 정상 메일이 음성 클래스이다.

for image, mask, text in test_loader:
  
  with torch.no_grad():
      output_prob = torch.sigmoid(output).cpu().detach().numpy()
      predict = (output_prob > 0.5).astype(int)
      print(f'predict:{predict}\n prob : {output_prob}\n\n')

최종적으로 이미지를 입력받고 이를 이진 분류로 변환한다.

반응형