개념
의미론적, 인스턴스, 팬옵틱
flowchart LR
IN["입력 이미지"] --> SEM["의미론적<br/>(픽셀 → 클래스)"]
IN --> INS["인스턴스<br/>(픽셀 → 객체 ID,<br/>전경 클래스만)"]
IN --> PAN["팬옵틱<br/>(모든 픽셀 → 클래스 + ID)"]
style SEM fill:#dbeafe,stroke:#2563eb
style INS fill:#fef3c7,stroke:#d97706
style PAN fill:#dcfce7,stroke:#16a34a
- 의미론적(Semantic) 세그멘테이션은 "이 픽셀은 도로, 저 픽셀은 자동차"라고 말합니다. 서로 붙어 있는 두 자동차는 하나의 덩어리로 합쳐집니다.
- 인스턴스(Instance) 세그멘테이션은 "이 픽셀은 자동차 #3, 저 픽셀은 자동차 #5"라고 말합니다. 배경 사물, 즉 하늘·도로·잔디 같은 배경 부류(stuff) 클래스는 보통 무시합니다.
- 팬옵틱(Panoptic) 세그멘테이션은 두 방식을 통합합니다. 모든 픽셀은 클래스 레이블을 받고, 모든 인스턴스는 고유 ID를 받으며, 배경 부류(stuff)와 개체 부류(things)가 모두 세그멘테이션됩니다.
이 레슨은 의미론적 세그멘테이션을 다룹니다. 다음 레슨인 Mask R-CNN에서는 인스턴스 세그멘테이션을 다룹니다.
U-Net의 형태
flowchart LR
subgraph ENC["인코더(수축 경로)"]
E1["64<br/>H x W"] --> E2["128<br/>H/2 x W/2"]
E2 --> E3["256<br/>H/4 x W/4"]
E3 --> E4["512<br/>H/8 x W/8"]
end
subgraph BOT["병목"]
B1["1024<br/>H/16 x W/16"]
end
subgraph DEC["디코더(확장 경로)"]
D4["512<br/>H/8 x W/8"] --> D3["256<br/>H/4 x W/4"]
D3 --> D2["128<br/>H/2 x W/2"]
D2 --> D1["64<br/>H x W"]
end
E4 --> B1 --> D4
E1 -. 스킵 .-> D1
E2 -. 스킵 .-> D2
E3 -. 스킵 .-> D3
E4 -. 스킵 .-> D4
D1 --> OUT["1x1 합성곱<br/>클래스"]
style ENC fill:#dbeafe,stroke:#2563eb
style BOT fill:#fef3c7,stroke:#d97706
style DEC fill:#dcfce7,stroke:#16a34a
인코더는 공간 해상도를 네 번 절반으로 줄이고 채널 수를 두 배로 늘립니다. 디코더는 반대로 공간 해상도를 네 번 두 배로 키우고 채널 수를 절반으로 줄입니다. 스킵 연결은 같은 해상도의 인코더 특징과 디코더 특징을 각 단계에서 이어 붙입니다. 마지막 1x1 합성곱(1x1 Convolution)은 전체 해상도에서 64 -> num_classes로 매핑합니다.
스킵 연결이 필요한 이유는 명확합니다. 디코더가 픽셀 단위 예측을 만들려고 할 때, 디코더가 본 것은 이미 매우 작은 특징 맵입니다. 스킵 연결이 없으면 인코더에서 압축되며 사라진 경계 정보를 복원할 수 없어서, 가장자리를 정확히 위치시키기 어렵습니다. 스킵 연결은 인코더가 내려가는 과정에서 계산한 고해상도 특징 맵을 디코더에게 건네줍니다.
전치 합성곱과 이중선형 업샘플(bilinear upsample)
디코더는 공간 차원을 키워야 합니다. 대표적인 선택지는 두 가지입니다.
- 전치 합성곱(Transposed Convolution,
nn.ConvTranspose2d) — 학습 가능한 업샘플링입니다. 역사적인 U-Net 기본값입니다. 보폭(stride)과 커널 크기(kernel size)가 고르게 맞지 않으면 체커보드 아티팩트(Checkerboard Artifact)가 생길 수 있습니다.
- 이중선형 업샘플(Bilinear upsample) + 3x3 합성곱 — 매끄럽게 업샘플링한 뒤 합성곱을 적용합니다. 아티팩트가 적고 파라미터도 적어서, 현재는 더 안전한 기본 선택지로 많이 쓰입니다.
두 방식 모두 실제 프로젝트에서 볼 수 있습니다. 첫 U-Net을 만들 때는 이중선형(bilinear) 방식이 더 안전합니다.
픽셀 격자 위의 교차 엔트로피
C개 클래스가 있는 의미론적 세그멘테이션에서 모델 출력은 (N, C, H, W)입니다. 정답은 정수 클래스 ID를 담은 (N, H, W)입니다. 교차 엔트로피는 분류 문제와 같지만 모든 공간 위치에 적용됩니다.
Loss = (n, h, w)에 대해 평균한 -log( softmax(logits[n, :, h, w])[target[n, h, w]] )
PyTorch의 F.cross_entropy는 이 형상(shape)을 기본으로 처리합니다. 별도 형상 변환(reshape)은 필요 없습니다.
Dice 손실이 필요한 이유
교차 엔트로피는 모든 픽셀을 동등하게 다룹니다. 한 클래스가 화면 대부분을 차지할 때, 예를 들어 의료 영상에서 99%는 배경이고 1%만 종양일 때는 이 접근이 문제가 됩니다. 네트워크는 모든 픽셀을 배경으로 예측하고도 99% 정확도를 얻을 수 있지만, 실제로는 쓸모없는 모델입니다.
Dice 손실(Dice Loss)은 예측 마스크와 정답 마스크의 겹침을 직접 최적화합니다.
Dice(p, y) = 2 * sum(p * y) / (sum(p) + sum(y) + epsilon)
Dice_loss = 1 - Dice
여기서 p는 한 클래스의 sigmoid/softmax 확률 맵이고, y는 이진 정답 마스크입니다. 두 마스크가 완벽히 겹칠 때만 손실이 0이 됩니다. 비율 기반 지표이므로 클래스 불균형의 영향을 훨씬 덜 받습니다.
실전에서는 보통 결합 손실(Combined Loss)을 씁니다.
L = L_cross_entropy + lambda * L_dice (lambda ~ 1)
교차 엔트로피는 학습 초기에 안정적인 그래디언트(gradient)를 주고, Dice는 학습 후반부에 실제 마스크 형태를 맞추는 데 집중하게 합니다. 이 조합은 의료 영상의 기본값에 가깝고, 클래스 불균형이 있는 데이터셋에서도 강력한 기준선입니다.
평가 지표
- 픽셀 정확도(Pixel Accuracy) — 맞춘 픽셀의 비율입니다. 계산은 쉽지만, 분류의 정확도처럼 불균형 데이터에서는 깨집니다.
- 클래스별 IoU(IoU per Class) — 각 클래스 마스크의 교집합 대비 합집합(intersection over union)입니다. 클래스 평균은 mIoU입니다.
- Dice(F1 on Pixels) — IoU와 비슷하며
Dice = 2 * IoU / (1 + IoU) 관계가 있습니다. 의료 영상에서는 Dice, 자율주행 커뮤니티에서는 IoU를 선호하는 편이며, 두 지표는 단조적으로 연결됩니다.
- 경계 F1(Boundary F1) — 예측 경계가 정답 경계에 얼마나 가까운지 측정합니다. 작은 경계 이동도 벌점으로 잡기 때문에 반도체 검사처럼 정밀도가 중요한 작업에서 중요합니다.
mIoU만 보고하지 말고 클래스별 IoU도 함께 보고해야 합니다. 평균 IoU는 아홉 클래스가 85%이고 한 클래스가 15%인 상황을 숨길 수 있습니다.
입력 해상도 트레이드오프
U-Net의 인코더는 해상도를 네 번 절반으로 줄이므로 입력 크기는 16으로 나누어떨어지는 것이 좋습니다. 의료 이미지는 512x512 또는 1024x1024가 많습니다. 자율주행에서 잘라낸(crop) 이미지는 2048x1024도 흔합니다. U-Net의 메모리 비용은 H * W * C_max에 비례하며, 1024x1024 입력과 1024개 병목 채널만으로도 순전파(forward pass)에 이미 수 GB의 VRAM이 필요합니다.
표준적인 우회 방법은 두 가지입니다.
- 입력을 타일(Tile)로 나눕니다. 겹침이 있는 256x256 타일을 처리한 뒤 다시 이어 붙입니다.
- 병목을 팽창 합성곱(Dilated Convolution)으로 바꿉니다. 공간 해상도를 더 높게 유지하면서 수용 영역(Receptive Field)을 넓힙니다. DeepLab 계열이 이 방향입니다.
첫 모델이라면 256x256 입력과 기준 채널(base channel) 64를 갖춘 U-Net은 8GB VRAM에서도 비교적 편하게 학습됩니다.
만들어보기
Step 1: 인코더 블록
배치 정규화(Batch Normalization)와 ReLU를 붙인 3x3 합성곱 두 개입니다. 첫 합성곱은 채널 수를 바꾸고, 두 번째 합성곱은 채널 수를 유지합니다.
import torch
import torch.nn as nn
import torch.nn.functional as F
class DoubleConv(nn.Module):
def __init__(self, in_c, out_c):
super().__init__()
self.net = nn.Sequential(
nn.Conv2d(in_c, out_c, kernel_size=3, padding=1, bias=False),
nn.BatchNorm2d(out_c),
nn.ReLU(inplace=True),
nn.Conv2d(out_c, out_c, kernel_size=3, padding=1, bias=False),
nn.BatchNorm2d(out_c),
nn.ReLU(inplace=True),
)
def forward(self, x):
return self.net(x)
이 블록은 전체 네트워크에서 반복해서 사용됩니다. bias=False를 두는 이유는 BN의 beta가 bias 역할을 처리하기 때문입니다.
Step 2: Down 블록과 Up 블록
class Down(nn.Module):
def __init__(self, in_c, out_c):
super().__init__()
self.net = nn.Sequential(
nn.MaxPool2d(2),
DoubleConv(in_c, out_c),
)
def forward(self, x):
return self.net(x)
class Up(nn.Module):
def __init__(self, in_c, out_c):
super().__init__()
self.up = nn.Upsample(scale_factor=2, mode="bilinear", align_corners=False)
self.conv = DoubleConv(in_c, out_c)
def forward(self, x, skip):
x = self.up(x)
if x.shape[-2:] != skip.shape[-2:]:
x = F.interpolate(x, size=skip.shape[-2:], mode="bilinear", align_corners=False)
x = torch.cat([skip, x], dim=1)
return self.conv(x)
공간 차원만 확인하는 shape[-2:] 검사는 입력 크기가 16으로 나누어떨어지지 않을 때를 처리합니다. 이어 붙이기(concat) 전에 F.interpolate로 안전하게 정렬합니다. 전체 형상(shape)을 비교하면 채널 수 차이까지 잡히는데, 채널 수 불일치는 조용히 보간(interpolate)할 문제가 아니라 크게 실패해야 하는 문제입니다.
Step 3: U-Net
class UNet(nn.Module):
def __init__(self, in_channels=3, num_classes=2, base=64):
super().__init__()
self.inc = DoubleConv(in_channels, base)
self.d1 = Down(base, base * 2)
self.d2 = Down(base * 2, base * 4)
self.d3 = Down(base * 4, base * 8)
self.d4 = Down(base * 8, base * 16)
self.u1 = Up(base * 16 + base * 8, base * 8)
self.u2 = Up(base * 8 + base * 4, base * 4)
self.u3 = Up(base * 4 + base * 2, base * 2)
self.u4 = Up(base * 2 + base, base)
self.outc = nn.Conv2d(base, num_classes, kernel_size=1)
def forward(self, x):
x1 = self.inc(x)
x2 = self.d1(x1)
x3 = self.d2(x2)
x4 = self.d3(x3)
x5 = self.d4(x4)
x = self.u1(x5, x4)
x = self.u2(x, x3)
x = self.u3(x, x2)
x = self.u4(x, x1)
return self.outc(x)
net = UNet(in_channels=3, num_classes=2, base=32)
x = torch.randn(1, 3, 256, 256)
print(f"출력: {net(x).shape}")
print(f"파라미터 수: {sum(p.numel() for p in net.parameters()):,}")
출력 shape는 (1, 2, 256, 256)입니다. 입력과 같은 공간 크기를 유지하고, 채널 수는 num_classes가 됩니다. base=32에서는 약 770만 개의 파라미터를 갖습니다.
Step 4: 손실
def dice_loss(logits, targets, num_classes, eps=1e-6):
probs = F.softmax(logits, dim=1)
targets_one_hot = F.one_hot(targets, num_classes).permute(0, 3, 1, 2).float()
dims = (0, 2, 3)
intersection = (probs * targets_one_hot).sum(dim=dims)
denom = probs.sum(dim=dims) + targets_one_hot.sum(dim=dims)
dice = (2 * intersection + eps) / (denom + eps)
return 1 - dice.mean()
def combined_loss(logits, targets, num_classes, lam=1.0):
ce = F.cross_entropy(logits, targets)
dc = dice_loss(logits, targets, num_classes)
return ce + lam * dc, {"ce": ce.item(), "dice": dc.item()}
Dice는 클래스별로 계산한 뒤 평균냅니다. 이를 매크로 Dice(Macro Dice)라고 볼 수 있습니다. eps는 배치(batch) 안에 특정 클래스가 없을 때 0으로 나누는 일을 막습니다.
Step 5: IoU 지표
@torch.no_grad()
def iou_per_class(logits, targets, num_classes):
preds = logits.argmax(dim=1)
ious = torch.zeros(num_classes)
for c in range(num_classes):
pred_c = (preds == c)
true_c = (targets == c)
inter = (pred_c & true_c).sum().float()
union = (pred_c | true_c).sum().float()
ious[c] = (inter / union) if union > 0 else torch.tensor(float("nan"))
return ious
길이 C의 벡터를 반환합니다. nan은 배치(batch) 안에 없는 클래스를 표시합니다. mIoU를 계산할 때는 이런 클래스를 평균에 넣지 않아야 합니다.
Step 6: 종단 간(end-to-end) 검증용 합성 데이터셋
색이 있는 배경 위에 도형을 생성합니다. 네트워크가 단순한 픽셀 색이 아니라 도형을 학습해야 합니다.
import numpy as np
from torch.utils.data import Dataset, DataLoader
def synthetic_segmentation(num_samples=200, size=64, seed=0):
rng = np.random.default_rng(seed)
images = np.zeros((num_samples, size, size, 3), dtype=np.float32)
masks = np.zeros((num_samples, size, size), dtype=np.int64)
for i in range(num_samples):
bg = rng.uniform(0, 1, (3,))
images[i] = bg
masks[i] = 0
num_shapes = rng.integers(1, 4)
for _ in range(num_shapes):
cls = int(rng.integers(1, 3))
color = rng.uniform(0, 1, (3,))
cx, cy = rng.integers(10, size - 10, size=2)
r = int(rng.integers(4, 12))
yy, xx = np.meshgrid(np.arange(size), np.arange(size), indexing="ij")
if cls == 1:
mask = (xx - cx) ** 2 + (yy - cy) ** 2 < r ** 2
else:
mask = (np.abs(xx - cx) < r) & (np.abs(yy - cy) < r)
images[i][mask] = color
masks[i][mask] = cls
images[i] += rng.normal(0, 0.02, images[i].shape)
images[i] = np.clip(images[i], 0, 1)
return images, masks
class SegDataset(Dataset):
def __init__(self, images, masks):
self.images = images
self.masks = masks
def __len__(self):
return len(self.images)
def __getitem__(self, i):
img = torch.from_numpy(self.images[i]).permute(2, 0, 1).float()
mask = torch.from_numpy(self.masks[i]).long()
return img, mask
클래스는 배경(0), 원(1), 사각형(2) 세 가지입니다. 네트워크는 도형을 구분하는 법을 배워야 합니다.
Step 7: 학습 루프
def train_one_epoch(model, loader, optimizer, device, num_classes):
model.train()
loss_sum, total = 0.0, 0
iou_sum = torch.zeros(num_classes)
for x, y in loader:
x, y = x.to(device), y.to(device)
logits = model(x)
loss, _ = combined_loss(logits, y, num_classes)
optimizer.zero_grad()
loss.backward()
optimizer.step()
loss_sum += loss.item() * x.size(0)
total += x.size(0)
iou_sum += iou_per_class(logits, y, num_classes).nan_to_num(0)
return loss_sum / total, iou_sum / len(loader)
합성 데이터셋에서 10-30 에폭(epoch) 실행하면 도형 클래스의 mIoU가 0.9를 넘는 것을 볼 수 있습니다. 여기서 nan_to_num(0)은 배치(batch)에 없는 클래스를 0으로 처리합니다. 정확한 클래스별 IoU를 원한다면 평가 단계에서 클래스 존재 여부를 마스킹하고 배치 전체에 대해 torch.nanmean을 사용해야 합니다.
활용하기
프로덕션에서는 segmentation_models_pytorch("smp")가 torchvision 또는 timm의 백본(backbone)과 함께 표준 세그멘테이션 아키텍처를 감싸 제공합니다. 세 줄이면 시작할 수 있습니다.
import segmentation_models_pytorch as smp
model = smp.Unet(
encoder_name="resnet34",
encoder_weights="imagenet",
in_channels=3,
classes=3,
)
실무에서 함께 알아두면 좋은 모델은 다음과 같습니다.
- DeepLabV3+는 최대 풀링(max-pool) 기반 다운샘플링을 팽창 합성곱으로 바꾸어 병목에서 해상도를 더 유지합니다. 위성·주행 데이터의 경계를 더 빠르게 잡는 데 유리합니다.
- SegFormer는 합성곱 인코더를 계층적 트랜스포머(Transformer)로 바꿉니다. 여러 벤치마크에서 현재 강력한 SOTA 기준선입니다.
- Mask2Former / OneFormer는 의미론적, 인스턴스, 팬옵틱 세그멘테이션을 하나의 아키텍처로 통합합니다.
세 모델 모두 smp 또는 transformers에서 같은 데이터 로더(data loader)로 교체해 사용할 수 있습니다.
산출물 만들기
이 레슨의 최종 산출물은 다음과 같습니다.
outputs/prompt-segmentation-task-picker.md — 주어진 과제에 대해 의미론적, 인스턴스, 팬옵틱 세그멘테이션 중 무엇을 선택할지 정하고 아키텍처를 제안하는 프롬프트입니다.
outputs/skill-segmentation-mask-inspector.md — 클래스 분포, 예측 마스크 통계, 과소 예측되거나 경계가 흐려진 클래스를 보고하는 skill입니다.
연습문제
- (쉬움) 이진 세그멘테이션 과제, 즉 전경과 배경을 나누는 문제를 위한
bce_dice_loss를 구현합니다. 전경이 픽셀의 5%인 합성 이진 데이터셋에서 결합 손실이 BCE만 사용할 때보다 더 빠르게 수렴하는지 확인합니다.
- (보통)
nn.Upsample + conv Up 블록을 nn.ConvTranspose2d Up 블록으로 바꿉니다. 합성 데이터셋에서 두 모델을 모두 학습하고 mIoU를 비교합니다. 전치 합성곱 버전에서 체커보드 아티팩트가 어디에 나타나는지 관찰합니다.
- (어려움) 실제 세그멘테이션 데이터셋(Oxford-IIIT Pets, Cityscapes mini split, 의료 영상 subset 등)을 가져와 U-Net을 학습합니다.
smp.Unet 기준 모델과 2 IoU point 이내가 되도록 맞춥니다. 클래스별 IoU를 보고하고, 손실에 Dice를 추가했을 때 어떤 클래스가 가장 크게 좋아졌는지 식별합니다.
핵심 용어
| 용어 | 흔한 설명 | 실제 의미 |
|---|
| 의미론적 세그멘테이션(Semantic Segmentation) | "모든 픽셀에 레이블 붙이기" | 픽셀 단위로 C개 클래스 중 하나를 분류합니다. 같은 클래스의 인스턴스는 합쳐집니다. |
| 인스턴스 세그멘테이션(Instance Segmentation) | "모든 객체에 레이블 붙이기" | 같은 클래스 안의 서로 다른 객체를 분리합니다. 보통 전경 객체 중심입니다. |
| 팬옵틱 세그멘테이션(Panoptic Segmentation) | "Semantic + instance" | 모든 픽셀은 클래스를 받고, thing 인스턴스는 고유 ID도 받습니다. |
| 스킵 연결(Skip Connection) | "U-Net bridge" | 같은 해상도의 인코더 특징을 디코더 특징에 concatenate합니다. 고주파 디테일을 보존합니다. |
| 전치 합성곱(Transposed Convolution) | "Deconvolution" | 학습 가능한 업샘플링입니다. 체커보드 아티팩트를 만들 수 있습니다. |
| Dice 손실(Dice Loss) | "Overlap loss" | `1 - 2 |
| mIoU | "Mean intersection over union" | 클래스별 IoU의 평균입니다. 세그멘테이션 커뮤니티의 표준 지표입니다. |
| 경계 F1(Boundary F1) | "Boundary accuracy" | 경계 픽셀에 대해서만 계산한 F1 점수(score)입니다. 정밀도가 중요한 작업에서 중요합니다. |
더 읽을거리