세그멘테이션 = 같은 위치의 픽셀값만 비교
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이야"
입력 (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로 가장 높은 확률의 클래스 선택
# 출력
output = model(img) # [배치, 3, 128, 128]
# 정답
mask = ... # [배치, 128, 128]
# Loss 계산
loss = criterion(output, mask)
CrossEntropyLoss가 자동으로 처리
1. output의 각 픽셀에서 3개 클래스 확률 계산 (softmax)
2. mask의 정답 클래스와 비교
3. 픽셀별 손실 계산 후 평균
문제 상황
입력: 100×100 이미지
출력: 100×100 마스크
→ 직접 비교하면 될 것 같은데?
실제로는 특징 추출이 필요
목적: 특징값 추출 (가로선, 세로선, 패턴 등)
100×100 (원본)
↓ Conv + MaxPool
50×50
↓ Conv + MaxPool
25×25
↓ 특징값만 남음
특징 추출의 의미
문제: 25×25로 줄었는데, 100×100 마스크와 비교해야 함
해결: 크기를 다시 키워야 함
25×25
↓ ConvTranspose2d (stride=2)
50×50
↓ ConvTranspose2d (stride=2)
100×100
점진적 확대의 이유
입력 이미지 (100×100)
↓
[인코더: 다운샘플링]
특징 추출 + 크기 축소
↓
중간 표현 (25×25)
↓
[디코더: 업샘플링]
크기 복원 + 클래스 예측
↓
출력 마스크 (100×100)
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, ...)
Conv2d(32, 64, ...)
ConvTranspose2d(64, 3, ...)
마지막 출력 채널 = 클래스 수
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):
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 = 세모
특징
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: 프로그램 사용
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 같은 값 생성
이유
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
↑ 모두 유효한 클래스!
회전, 크롭 등은 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)
주의점
정의: 이미지를 늘리거나 줄일 때 없는 픽셀을 채우거나 픽셀을 변경하는 방법
평균 보간 (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) 값 사용
| 보간법 | 이미지 사용 | 마스크 사용 | 장점 | 단점 |
|---|---|---|---|---|
| NEAREST | △ | O 필수 | 빠름, 값 보존 | 계단 현상 |
| BILINEAR | O | X | 부드러움 | 새 값 생성 |
| BICUBIC | O | X | 고품질 | 느림, 새 값 생성 |
# 모델 정의
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()
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]으로 변경 불가!
# 사전학습 모델 불러오기
model = deeplabv3_resnet50(weights=weights)
# 마지막 레이어 교체
model.classifier[4] = nn.Conv2d(256, num_classes, 1)
원리
# weight 없이 모델 생성
model = deeplabv3_resnet50(
weights=None, # 사전학습 없음
num_classes=3 # 내 클래스 수
)
원리
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
TensorFlow/Keras
# 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)
핵심 이해:
1. 입력 이미지를 작게 만들어서 특징 추출 (다운샘플링)
2. 특징을 다시 원본 크기로 키움 (업샘플링)
3. 각 픽셀마다 클래스 예측
4. 정답 마스크와 비교하여 학습
핵심 이해:
핵심 이해:
핵심 이해:
핵심 이해:
세그멘테이션 본질: 같은 위치 픽셀값 비교, Y값이 3이면 예측도 3
입출력 형태: X=[B,C,H,W], Y=[B,H,W], 출력=[B,클래스,H,W]
다운샘플링: 특징 추출을 위해 크기 축소 (128→64→32)
업샘플링: 원본 크기 복원을 위해 확대 (32→64→128)
마스크 데이터: RGB 없음, 숫자만, 픽셀마다 레이블 번호
정규화 금지: Y값은 절대 정규화하면 안 됨 (정수 유지 필수)
NEAREST 필수: 마스크 리사이즈는 반드시 NEAREST 보간
증강 동일: 이미지와 마스크는 같은 변환 적용
Weight 우선: 사전학습 weight가 있으면 num_classes 무시됨
행렬 제약: Weight는 특정 클래스 수에 맞춰져 있어 변경 불가
1. 간단한 FCN 직접 구현
2. 보간법 실험
3. 마스크 데이터 만들기
4. 전이학습 실험
U-Net과 비교
다양한 업샘플링 기법
Loss 함수 연구
1. 커스텀 세그멘테이션
2. 의료 영상 분석
3. 배경 제거 앱
논문
온라인 자료
코드 저장소