FCN 모델 구조 및 마스크 데이터 처리 - 핵심 재정리

JJang-404·2025년 11월 25일

FCN 모델 구조 및 마스크 데이터 처리 - 핵심 재정리

학습 목표

  • FCN 모델의 입출력 구조 완벽 이해
  • 마스크 데이터의 본질과 처리 방법 파악
  • 다운샘플링과 업샘플링의 필요성 이해
  • 보간법(Interpolation)의 중요성 재확인
  • 전이학습 시 weight와 num_classes의 관계 이해

1. 세그멘테이션의 본질: 픽셀 단위 비교

핵심 개념

세그멘테이션 = 같은 위치의 픽셀값만 비교

X (입력 이미지):     Y (정답 마스크):      Pred (예측):
[이미지 픽셀]        0 0 0 0             0 0 0 0
                    0 1 1 0             0 1 1 0
                    0 2 2 0             0 2 2 0
                    0 0 0 0             0 0 0 0

목표: 예측값과 Y값이 같은 위치에서 같은 숫자가 나오도록 학습

동작 원리

1. 정답(Y) 구조

Y 값 (마스크):
- 동그라미 영역 → 1
- 세모 영역 → 2
- 나머지 → 0

예시:
0 0 0 0
1 1 1 0
0 0 0 2
0 0 2 2

2. 예측 과정

모델 출력 → 각 픽셀마다 0, 1, 2 중 하나 예측
Y값과 비교 → 같으면 정답, 다르면 오차
Loss 계산 → 오차를 최소화하도록 학습

핵심 포인트

"Y값이 3인데 나도 3이야"

  • Y값에는 구역값이 픽셀 단위로 들어가 있음
  • 예측도 같은 크기, 같은 위치에서 숫자를 맞춰야 함
  • 마스크 자료 = 픽셀마다 레이블 번호가 적혀있는 데이터

2. FCN의 입출력 구조 이해

입력과 출력의 형태

입력 (X)

형태: [배치, 채널, 높이, 너비]
예시: [32, 3, 128, 128]
      ↑   ↑   ↑    ↑
     배치 RGB 높이  너비

출력 (Y/마스크)

형태: [배치, 높이, 너비]
예시: [32, 128, 128]
      ↑    ↑    ↑
     배치  높이  너비

주의: RGB 채널 없음! 숫자값만!

모델의 출력

모델이 내보내야 하는 것

출력 형태: [배치, 클래스 수, 높이, 너비]
예시: [32, 3, 128, 128]
      ↑   ↑   ↑    ↑
     배치 라벨수 높이  너비

왜 클래스 수가 채널처럼 나오는가?

각 픽셀에서 0일 확률, 1일 확률, 2일 확률을 모두 계산해야 하기 때문!

픽셀 (i, j)에서:
- 채널 0: 클래스 0일 확률 (배경)
- 채널 1: 클래스 1일 확률 (동그라미)
- 채널 2: 클래스 2일 확률 (세모)

→ argmax로 가장 높은 확률의 클래스 선택

Loss 함수의 비교

# 출력
output = model(img)  # [배치, 3, 128, 128]

# 정답
mask = ...           # [배치, 128, 128]

# Loss 계산
loss = criterion(output, mask)

CrossEntropyLoss가 자동으로 처리
1. output의 각 픽셀에서 3개 클래스 확률 계산 (softmax)
2. mask의 정답 클래스와 비교
3. 픽셀별 손실 계산 후 평균


3. 다운샘플링과 업샘플링의 필요성

왜 크기를 줄였다가 늘리는가?

문제 상황

입력: 100×100 이미지
출력: 100×100 마스크

→ 직접 비교하면 될 것 같은데?

실제로는 특징 추출이 필요

다운샘플링 (인코딩)

목적: 특징값 추출 (가로선, 세로선, 패턴 등)

100×100 (원본)
    ↓ Conv + MaxPool
50×50
    ↓ Conv + MaxPool
25×25
    ↓ 특징값만 남음

특징 추출의 의미

  • 원본 이미지 → 복잡함
  • 특징값 → 핵심 정보만 압축
  • 25×25에 중요한 정보만 남음

업샘플링 (디코딩)

문제: 25×25로 줄었는데, 100×100 마스크와 비교해야 함

해결: 크기를 다시 키워야 함

25×25
    ↓ ConvTranspose2d (stride=2)
50×50
    ↓ ConvTranspose2d (stride=2)
100×100

점진적 확대의 이유

  • 25×25 → 100×100 (한 번에): 피처맵 손상
  • 25×25 → 50×50 → 100×100 (단계적): 부드러운 복원

전체 흐름

입력 이미지 (100×100)
    ↓
[인코더: 다운샘플링]
특징 추출 + 크기 축소
    ↓
중간 표현 (25×25)
    ↓
[디코더: 업샘플링]
크기 복원 + 클래스 예측
    ↓
출력 마스크 (100×100)

4. FCN 모델 구조 상세 설명

간단한 FCN 구조

class FCN(nn.Module):
    def __init__(self):
        super().__init__()
        
        # 인코더
        self.conv1 = nn.Conv2d(3, 32, 3, stride=1, padding=1)
        self.pool = nn.MaxPool2d(2, 2)
        
        # 디코더
        self.upconv = nn.ConvTranspose2d(32, 3, 2, stride=2)
    
    def forward(self, x):
        # 인코더
        x = torch.relu(self.conv1(x))  # [B, 32, 128, 128]
        x = self.pool(x)                # [B, 32, 64, 64]
        
        # 디코더
        x = self.upconv(x)              # [B, 3, 128, 128]
        
        return x

크기 변화 추적

입력: [배치, 3, 128, 128]

↓ Conv2d(3→32, k=3, s=1, p=1)
[배치, 32, 128, 128]  # 크기 변화 없음 (padding=1)

↓ MaxPool2d(k=2, s=2)
[배치, 32, 64, 64]    # 1/2로 축소

↓ ConvTranspose2d(32→3, k=2, s=2)
[배치, 3, 128, 128]   # 2배로 확대

채널 수의 의미

Conv2d(3, 32, ...)

  • 입력: 3채널 (RGB)
  • 출력: 32채널 (특징맵 32개)

Conv2d(32, 64, ...)

  • 입력: 32채널
  • 출력: 64채널 (특징맵 64개)

ConvTranspose2d(64, 3, ...)

  • 입력: 64채널
  • 출력: 3채널 (클래스 3개)

마지막 출력 채널 = 클래스 수

더 복잡한 FCN 구조

class FCN(nn.Module):
    def __init__(self):
        super().__init__()
        
        # 인코더
        self.conv1 = nn.Conv2d(3, 64, 3, padding=1)
        self.conv2 = nn.Conv2d(64, 128, 3, padding=1)
        self.pool = nn.MaxPool2d(2, 2)
        
        # 디코더
        self.up1 = nn.ConvTranspose2d(128, 64, 2, stride=2)
        self.up2 = nn.ConvTranspose2d(64, 3, 2, stride=2)
    
    def forward(self, x):
        # 인코더
        x = torch.relu(self.conv1(x))  # [B, 64, 128, 128]
        x = self.pool(x)                # [B, 64, 64, 64]
        
        x = torch.relu(self.conv2(x))  # [B, 128, 64, 64]
        x = self.pool(x)                # [B, 128, 32, 32]
        
        # 디코더
        x = self.up1(x)                 # [B, 64, 64, 64]
        x = self.up2(x)                 # [B, 3, 128, 128]
        
        return x

크기 변화

128×128 → 64×64 → 32×32 (다운샘플링)
32×32 → 64×64 → 128×128 (업샘플링)

5. 마스크 데이터의 본질

마스크란 무엇인가

정의: 픽셀마다 레이블 번호가 적혀있는 데이터

마스크 예시 (5×5):
0 0 0 0 0
0 1 1 1 0
1 1 1 1 0
0 0 0 2 2
0 0 2 2 2

0 = 배경
1 = 동그라미
2 = 세모

특징

  • RGB 채널 없음
  • 숫자값만 존재 (0, 1, 2, ...)
  • 각 숫자는 클래스 레이블

마스크 데이터 형태

Numpy 배열로 저장

mask = np.array([
    [0, 0, 0, 1, 2, 0],
    [0, 0, 1, 1, 1, 0]
])

# 저장
np.save('mask.npy', mask)
np.savez('mask.npz', mask=mask)

다양한 저장 형식

1. Numpy 배열 (.npy, .npz)

mask = np.array([[0,0,0,1,2,0], [0,0,1,1,1,0]])

2. XML 형식

<mask>
    <row>0,0,0,1,2,0</row>
    <row>0,0,1,1,1,0</row>
</mask>

3. JSON 형식

{
  "mask": [[0,0,0,1,2,0], [0,0,1,1,1,0]]
}

4. 1차원 배열

mask_flat = [0,0,0,0,1,2,0,0,1,1,1,0]
# 나중에 reshape 필요

5. Segmentation 좌표

# 폴리곤 좌표만 제공
segmentation = [[x1, y1, x2, y2, x3, y3, ...]]
# 우리가 직접 마스크로 변환해야 함

마스크 생성 방법

방법 1: 직접 만들기

import cv2
import numpy as np

# 빈 마스크 생성
mask = np.zeros((height, width), dtype=np.uint8)

# 폴리곤 좌표
polygon = np.array([[x1, y1], [x2, y2], [x3, y3], ...])

# 마스크에 채우기
cv2.fillPoly(mask, [polygon], 1)  # 1번 클래스로 채움

방법 2: 프로그램 사용

  • Roboflow
  • CVAT
  • Labelme
  • 등등...

6. 마스크 전처리의 핵심 규칙

절대 규칙 1: Y값은 정규화 금지

X 데이터 (이미지)

# 정규화 가능
transform = T.Compose([
    T.ToTensor(),           # [0, 255] → [0, 1]
    T.Normalize(mean, std)  # 표준화
])

Y 데이터 (마스크)

# 정규화 절대 금지!
mask = torch.tensor(mask, dtype=torch.long)

# 잘못된 예:
# mask = mask / 255.0  # X! 0.004, 0.008 같은 값 생성

이유

  • 마스크 값: 0, 1, 2 (정확한 정수)
  • 정규화하면: 0.0, 0.004, 0.008 (소수)
  • CrossEntropyLoss는 정수 레이블만 받음!

절대 규칙 2: 리사이즈 시 보간법 주의

X 데이터 (이미지)

# 평균 보간 가능
img = T.Resize((128, 128))(img)
# 또는
img = T.Resize((128, 128), interpolation=T.InterpolationMode.BILINEAR)(img)

Y 데이터 (마스크)

# NEAREST만 사용!
mask = T.Resize(
    (128, 128), 
    interpolation=T.InterpolationMode.NEAREST
)(mask)

왜 NEAREST만 사용하는가?

원본 마스크 (4×4):
0 0 1 1
0 0 1 1
2 2 0 0
2 2 0 0

↓ BILINEAR 사용 시 (2×2로 축소)
0.0  1.0
1.5  0.5
  ↑ 1.5는 없는 클래스!

↓ NEAREST 사용 시 (2×2로 축소)
0  1
2  0
  ↑ 모두 유효한 클래스!

절대 규칙 3: 증강 시 X, Y 동일하게

회전, 크롭 등은 X, Y 함께

# 같은 각도로 회전
angle = random.uniform(-30, 30)
img = T.functional.rotate(img, angle)
mask = T.functional.rotate(mask, angle, interpolation=T.InterpolationMode.NEAREST)

# 같은 위치 크롭
i, j, h, w = T.RandomCrop.get_params(img, (128, 128))
img = T.functional.crop(img, i, j, h, w)
mask = T.functional.crop(mask, i, j, h, w)

주의점

  • 이미지와 마스크는 정확히 같은 변환 적용
  • 마스크는 항상 NEAREST 보간

7. 보간법 상세 설명

보간법이란

정의: 이미지를 늘리거나 줄일 때 없는 픽셀을 채우거나 픽셀을 변경하는 방법

이미지에서의 보간법

평균 보간 (BILINEAR) 가능한 이유

이미지 픽셀값:
100  102  104  106
98   100  102  104
...

특징:
- 비슷한 값들이 인접
- 평균을 내도 자연스러움
- 경계가 부드러워짐

4×4를 2×2로 축소

원본:
100  102 | 104  106
98   100 | 102  104
---------+---------
95   97  | 99   101
93   95  | 97   99

↓ BILINEAR

결과:
100  103.5
95   99

마스크에서의 보간법

평균 보간이 불가능한 이유

마스크 값:
0  0 | 1  1
0  0 | 1  1
-----+-----
2  2 | 0  0
2  2 | 0  0

↓ BILINEAR 사용 시

0.0  0.5
1.0  0.5
  ↑ 0.5는 없는 클래스!

↓ NEAREST 사용 시

0  1
2  0
  ↑ 모두 유효!

NEAREST 동작 방식

가장 가까운 픽셀 값을 그대로 사용

4×4를 2×2로 축소:
- (0, 0) 위치 → 원본 (0, 0) 값 사용
- (0, 1) 위치 → 원본 (0, 2) 값 사용
- (1, 0) 위치 → 원본 (2, 0) 값 사용
- (1, 1) 위치 → 원본 (2, 2) 값 사용

보간법 비교 표

보간법이미지 사용마스크 사용장점단점
NEARESTO 필수빠름, 값 보존계단 현상
BILINEAROX부드러움새 값 생성
BICUBICOX고품질느림, 새 값 생성

8. 학습 코드 전체 흐름

의사코드

# 모델 정의
class FCN(nn.Module):
    def __init__(self):
        super().__init__()
        self.conv1 = nn.Conv2d(3, 32, 3, stride=2, padding=1)
        self.pool = nn.MaxPool2d(2, 2)
        # ... 인코더 레이어들
        
        self.upconv = nn.ConvTranspose2d(32, 3, 2, stride=2)
        # ... 디코더 레이어들
    
    def forward(self, x):
        x = torch.relu(self.conv1(x))  # [B, 32, 128, 128]
        x = self.pool(x)                # [B, 32, 64, 64]
        # ... 인코딩
        
        x = self.upconv(x)              # [B, 3, 128, 128]
        # ... 디코딩
        
        return x

# 모델 생성
model = FCN()

# 손실 함수 (softmax 내장)
criterion = nn.CrossEntropyLoss()

# 학습 루프
for img, mask in dataloader:
    # img: [B, 3, 128, 128]
    # mask: [B, 128, 128]
    
    # Forward
    output = model(img)  # [B, 3, 128, 128]
    
    # Loss
    loss = criterion(output, mask)
    
    # Backward
    loss.backward()
    optimizer.step()

실제 코드 예시

import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import Dataset, DataLoader
import torchvision.transforms as T

# 데이터셋
class SegDataset(Dataset):
    def __init__(self):
        # 데이터 로드
        pass
    
    def __getitem__(self, idx):
        img = ...  # 이미지 로드
        mask = ... # 마스크 로드
        
        # 이미지 전처리
        img = T.Resize((128, 128))(img)
        img = T.ToTensor()(img)
        
        # 마스크 전처리 (NEAREST 필수!)
        mask = T.Resize(
            (128, 128), 
            interpolation=T.InterpolationMode.NEAREST
        )(mask)
        mask = torch.tensor(mask, dtype=torch.long)
        
        return img, mask

# 데이터로더
dataset = SegDataset()
loader = DataLoader(dataset, batch_size=8, shuffle=True)

# 모델
model = FCN()

# 손실 함수 & 옵티마이저
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=0.001)

# 학습
for epoch in range(10):
    for img, mask in loader:
        optimizer.zero_grad()
        
        output = model(img)
        loss = criterion(output, mask)
        
        loss.backward()
        optimizer.step()

9. 전이학습: Weight와 num_classes의 관계

질문의 핵심

Q: weight를 지정하면 항상 num_classes를 지정할 수 없는 건가요?

답변: 행렬 곱셈의 제약

핵심 개념

사전학습된 weight는 특정 클래스 수에 맞춰져 있음

예: ImageNet 사전학습 모델
- 마지막 FC Layer: [512, 1000]
  (512 특징 → 1000 클래스)

내 데이터가 3 클래스라면?

내가 원하는 것: [512, 3]

하지만 사전학습 weight: [512, 1000]

→ 행렬 크기가 안 맞음!

행렬 곱셈의 제약

행렬 곱셈 규칙

A × B = C

A: [m, n]
B: [n, p]
C: [m, p]

A의 열 수 = B의 행 수 (n)가 같아야 함!

FCN의 경우

특징 벡터: [배치, 512, H, W]
Weight: [512, num_classes]

→ 512는 고정! num_classes만 변경 가능

하지만 사전학습 weight는 [512, 21]로 고정되어 있음
→ [512, 3]으로 변경 불가!

해결 방법 1: 마지막 레이어만 교체

# 사전학습 모델 불러오기
model = deeplabv3_resnet50(weights=weights)

# 마지막 레이어 교체
model.classifier[4] = nn.Conv2d(256, num_classes, 1)

원리

  • 사전학습 weight는 그대로 사용
  • 마지막 레이어만 새로 초기화
  • 클래스 수를 자유롭게 변경 가능

해결 방법 2: weight 없이 처음부터

# weight 없이 모델 생성
model = deeplabv3_resnet50(
    weights=None,           # 사전학습 없음
    num_classes=3           # 내 클래스 수
)

원리

  • 모든 weight를 랜덤 초기화
  • 클래스 수를 자유롭게 지정 가능
  • 하지만 학습 시간이 오래 걸림

우선순위 정리

weight 인자의 우선순위 > num_classes

# Case 1: 둘 다 지정
model = deeplabv3_resnet50(
    weights=DeepLabV3_ResNet50_Weights.DEFAULT,  # 21 클래스
    num_classes=3                                 # 무시됨!
)
# 결과: 21 클래스 모델 (weight 우선)

# Case 2: weight만 지정
model = deeplabv3_resnet50(
    weights=DeepLabV3_ResNet50_Weights.DEFAULT
)
# 결과: 21 클래스 모델

# Case 3: num_classes만 지정
model = deeplabv3_resnet50(
    weights=None,
    num_classes=3
)
# 결과: 3 클래스 모델

PyTorch vs TensorFlow

PyTorch

  • 명시적: weight와 num_classes 동시 지정 불가
  • 사용자가 직접 레이어 교체 필요

TensorFlow/Keras

  • 자동: 자동으로 마지막 레이어 교체
  • include_top=False 옵션으로 해결
# TensorFlow 예시
base_model = tf.keras.applications.ResNet50(
    weights='imagenet',
    include_top=False  # 마지막 레이어 제외
)

# 새로운 마지막 레이어 추가
x = base_model.output
x = GlobalAveragePooling2D()(x)
predictions = Dense(num_classes, activation='softmax')(x)

model = Model(inputs=base_model.input, outputs=predictions)

Q&A 정리

Q1. "모델 구조가 이해가 안 된다"

핵심 이해:
1. 입력 이미지를 작게 만들어서 특징 추출 (다운샘플링)
2. 특징을 다시 원본 크기로 키움 (업샘플링)
3. 각 픽셀마다 클래스 예측
4. 정답 마스크와 비교하여 학습

Q2. "마스크 데이터가 이해가 안 된다"

핵심 이해:

  • 마스크 = 픽셀마다 레이블 번호가 적힌 2D 배열
  • RGB 없음, 숫자만 존재
  • 0, 1, 2 등의 정수값
  • 이미지와 같은 크기

Q3. "왜 크기를 줄였다가 다시 키우나?"

핵심 이해:

  • 줄이는 이유: 특징 추출 (중요한 정보만 남김)
  • 키우는 이유: 원본 크기와 비교해야 함
  • 점진적 확대: 한 번에 키우면 품질 저하

Q4. "weight와 num_classes는 왜 같이 못 쓰나?"

핵심 이해:

  • 사전학습 weight는 특정 클래스 수에 고정됨
  • 행렬 곱셈 때문에 크기 변경 불가
  • 마지막 레이어만 교체하거나
  • 사전학습 없이 처음부터 시작

Q5. "보간법은 왜 중요한가?"

핵심 이해:

  • 이미지: 평균 보간 가능 (비슷한 값)
  • 마스크: NEAREST만 가능 (정확한 정수)
  • 잘못 쓰면 1.5 같은 이상한 클래스 생성

핵심 정리

  1. 세그멘테이션 본질: 같은 위치 픽셀값 비교, Y값이 3이면 예측도 3

  2. 입출력 형태: X=[B,C,H,W], Y=[B,H,W], 출력=[B,클래스,H,W]

  3. 다운샘플링: 특징 추출을 위해 크기 축소 (128→64→32)

  4. 업샘플링: 원본 크기 복원을 위해 확대 (32→64→128)

  5. 마스크 데이터: RGB 없음, 숫자만, 픽셀마다 레이블 번호

  6. 정규화 금지: Y값은 절대 정규화하면 안 됨 (정수 유지 필수)

  7. NEAREST 필수: 마스크 리사이즈는 반드시 NEAREST 보간

  8. 증강 동일: 이미지와 마스크는 같은 변환 적용

  9. Weight 우선: 사전학습 weight가 있으면 num_classes 무시됨

  10. 행렬 제약: Weight는 특정 클래스 수에 맞춰져 있어 변경 불가


추가 학습 권장 사항

실습 과제

1. 간단한 FCN 직접 구현

  • Conv + MaxPool 2~3번
  • ConvTranspose2d로 업샘플링
  • 크기 변화 직접 확인

2. 보간법 실험

  • 마스크에 BILINEAR 사용해보기
  • 어떤 문제가 생기는지 확인
  • NEAREST와 비교

3. 마스크 데이터 만들기

  • cv2.fillPoly()로 직접 생성
  • 여러 클래스 마스크 제작
  • 시각화 및 확인

4. 전이학습 실험

  • weight 있이 / 없이 비교
  • num_classes 변경 시도
  • 마지막 레이어 교체 연습

심화 학습

U-Net과 비교

  • FCN의 문제점을 어떻게 해결했는지
  • Skip connection의 역할
  • 성능 차이 비교

다양한 업샘플링 기법

  • ConvTranspose2d의 한계
  • Upsampling + Conv 조합
  • Sub-pixel Convolution

Loss 함수 연구

  • CrossEntropyLoss의 한계
  • Dice Loss, Focal Loss
  • 클래스 불균형 문제

프로젝트 아이디어

1. 커스텀 세그멘테이션

  • 자신만의 데이터셋 제작
  • 3~5개 클래스로 시작
  • FCN으로 학습 및 평가

2. 의료 영상 분석

  • 공개 의료 데이터셋 활용
  • 장기, 종양 세그멘테이션
  • 높은 정확도 달성

3. 배경 제거 앱

  • 사람/배경 2클래스
  • 실시간 처리 최적화
  • 모바일 배포

참고 자료

논문

  • FCN: "Fully Convolutional Networks for Semantic Segmentation" (Long et al., 2015)
  • U-Net: "U-Net: Convolutional Networks for Biomedical Image Segmentation" (Ronneberger et al., 2015)

온라인 자료

코드 저장소

  • PyTorch Examples
  • Segmentation Models PyTorch
  • Awesome Semantic Segmentation
profile
V I S I O N _ E N G I N E E R

0개의 댓글