Siamese 기반 변화 탐지(Change Detection)이란?
변화 탐지(Change Detection)는 두 시점(A/B)의 이미지를 비교해 변화가 발생한 영역을 픽셀 단위로 분할(Segmentation)하는 컴퓨터 비전 태스크입니다. 단순히 “다르다/같다”를 판별하는 것이 아니라, 어디가 얼마나 변했는지를 마스크 형태로 예측하는 것이 핵심입니다.
Siamese 기반 모델은 두 입력 이미지(A/B)를 같은 가중치(shared weights)를 가진 인코더로 각각 특징을 추출한 뒤, 특징 차이/결합을 통해 변화 영역을 복원합니다. 이번 글에서는 LEVIR 데이터셋을 기준으로 Siamese 구조 모델을 구성하고 학습하는 방법을 PyTorch로 정리합니다.
- 예: 건물 신축/철거, 도로 확장 등 변화 영역 자동 탐지
- 예: 재난 전/후 피해 지역 추정(변화 마스크 생성)
목표
- LEVIR (A/B + Mask) 데이터셋을 그대로 활용해 학습 파이프라인 구성
- Siamese Encoder + Decoder 기반 변화 탐지 모델 정의
- BCEWithLogits + Dice Loss 조합으로 학습 안정화
- IoU/F1 평가 및 간단한 추론/시각화까지 연결
학습을 위한 데이터 구조
이전 글에서 구성한 LEVIR 데이터셋은 기본적으로 아래 형태를 가집니다. (A/B 두 시점 이미지 + 변화 마스크)
LEVIR-CD/
train/
A/
xxx.png
B/
xxx.png
label/
xxx.png
val/
A/
B/
label/
중요한 점은 transform이 이미지(A/B)와 마스크(label)에 동시에 동일하게 적용되어야 한다는 것입니다. 이미지에만 Resize/Flip이 적용되면 학습이 바로 깨집니다.
PyTorch Transform (A/B/Mask 동시 적용)
아래는 가장 기본적인 형태의 동기화 Transform 예시입니다. 랜덤 플립/크롭 등을 넣을 때도 항상 A/B/Mask에 동일한 파라미터로 적용해야 합니다.
import random
import torchvision.transforms.functional as TF
class CDCompose:
def __init__(self, transforms):
self.transforms = transforms
def __call__(self, imgA, imgB, mask):
for t in self.transforms:
imgA, imgB, mask = t(imgA, imgB, mask)
return imgA, imgB, mask
class CDToTensor:
def __call__(self, imgA, imgB, mask):
imgA = TF.to_tensor(imgA) # 0~1
imgB = TF.to_tensor(imgB)
# label은 0/255로 들어오는 경우가 많아서 0/1로 정규화
mask = TF.to_tensor(mask)
mask = (mask > 0.5).float()
return imgA, imgB, mask
class CDResize:
def __init__(self, size):
self.size = size # (H,W)
def __call__(self, imgA, imgB, mask):
imgA = TF.resize(imgA, self.size)
imgB = TF.resize(imgB, self.size)
# mask는 보간으로 값이 흐려지면 안 되므로 NEAREST 사용
mask = TF.resize(mask, self.size, interpolation=TF.InterpolationMode.NEAREST)
return imgA, imgB, mask
class CDRandomHFlip:
def __init__(self, p=0.5):
self.p = p
def __call__(self, imgA, imgB, mask):
if random.random() < self.p:
imgA = TF.hflip(imgA)
imgB = TF.hflip(imgB)
mask = TF.hflip(mask)
return imgA, imgB, mask
def get_cd_transforms(train=True, size=(512, 512)):
t = [CDResize(size)]
if train:
t.append(CDRandomHFlip(0.5))
t.append(CDToTensor())
return CDCompose(t)
Siamese 변화 탐지 모델 구성
모델 개요
- 입력: (imgA, imgB) 두 장의 이미지
- Encoder: shared weights (Siamese)
- Feature Fusion: |FA - FB| (절대 차이) 또는 concat([FA, FB, |FA-FB|])
- Decoder: 변화 마스크(1채널) 복원
아래는 학습/튜토리얼 용도로 가장 안정적인 가벼운 Siamese U-Net 스타일 구현입니다. (너무 복잡하게 가지 않고, 이후 성능 개선도 가능한 형태로 작성했습니다)
import torch
import torch.nn as nn
import torch.nn.functional as F
class ConvBNReLU(nn.Module):
def __init__(self, in_ch, out_ch, k=3, s=1, p=1):
super().__init__()
self.block = nn.Sequential(
nn.Conv2d(in_ch, out_ch, k, s, p, bias=False),
nn.BatchNorm2d(out_ch),
nn.ReLU(inplace=True)
)
def forward(self, x):
return self.block(x)
class Encoder(nn.Module):
def __init__(self, in_ch=3, base=32):
super().__init__()
self.c1 = nn.Sequential(ConvBNReLU(in_ch, base), ConvBNReLU(base, base))
self.p1 = nn.MaxPool2d(2)
self.c2 = nn.Sequential(ConvBNReLU(base, base*2), ConvBNReLU(base*2, base*2))
self.p2 = nn.MaxPool2d(2)
self.c3 = nn.Sequential(ConvBNReLU(base*2, base*4), ConvBNReLU(base*4, base*4))
self.p3 = nn.MaxPool2d(2)
self.c4 = nn.Sequential(ConvBNReLU(base*4, base*8), ConvBNReLU(base*8, base*8))
def forward(self, x):
f1 = self.c1(x) # (B, base, H, W)
x = self.p1(f1)
f2 = self.c2(x) # (B, base*2, H/2, W/2)
x = self.p2(f2)
f3 = self.c3(x) # (B, base*4, H/4, W/4)
x = self.p3(f3)
f4 = self.c4(x) # (B, base*8, H/8, W/8)
return f1, f2, f3, f4
class UpBlock(nn.Module):
def __init__(self, in_ch, skip_ch, out_ch):
super().__init__()
self.up = nn.ConvTranspose2d(in_ch, out_ch, kernel_size=2, stride=2)
self.conv = nn.Sequential(
ConvBNReLU(out_ch + skip_ch, out_ch),
ConvBNReLU(out_ch, out_ch)
)
def forward(self, x, skip):
x = self.up(x)
# size mismatch 방어(입력 크기가 2의 배수가 아닐 때)
if x.shape[-2:] != skip.shape[-2:]:
x = F.interpolate(x, size=skip.shape[-2:], mode="bilinear", align_corners=False)
x = torch.cat([x, skip], dim=1)
return self.conv(x)
class SiameseChangeNet(nn.Module):
def __init__(self, base=32):
super().__init__()
self.enc = Encoder(in_ch=3, base=base) # shared
# fusion channel: concat([FA, FB, |FA-FB|]) => 3x
self.fuse4 = ConvBNReLU(base*8*3, base*8)
self.up3 = UpBlock(base*8, base*4*3, base*4)
self.up2 = UpBlock(base*4, base*2*3, base*2)
self.up1 = UpBlock(base*2, base*1*3, base*1)
self.head = nn.Conv2d(base, 1, kernel_size=1)
def forward(self, a, b):
a1, a2, a3, a4 = self.enc(a)
b1, b2, b3, b4 = self.enc(b)
d1 = torch.abs(a1 - b1)
d2 = torch.abs(a2 - b2)
d3 = torch.abs(a3 - b3)
d4 = torch.abs(a4 - b4)
s1 = torch.cat([a1, b1, d1], dim=1)
s2 = torch.cat([a2, b2, d2], dim=1)
s3 = torch.cat([a3, b3, d3], dim=1)
s4 = torch.cat([a4, b4, d4], dim=1)
x = self.fuse4(s4)
x = self.up3(x, s3)
x = self.up2(x, s2)
x = self.up1(x, s1)
logits = self.head(x) # (B,1,H,W)
return logits
출력은 sigmoid를 통과하기 전의 logits이며, 학습 시 BCEWithLogitsLoss를 사용하면 수치적으로 안정적입니다.
Loss 구성 (BCE + Dice)
변화 탐지는 보통 변화 픽셀이 매우 적은 클래스 불균형이 발생합니다. BCE만 쓰면 변화 영역이 얇게 사라지거나 배경만 찍는 방향으로 가는 경우가 있어, Dice Loss를 함께 쓰면 안정적인 경우가 많습니다.
class DiceLoss(nn.Module):
def __init__(self, eps=1e-6):
super().__init__()
self.eps = eps
def forward(self, logits, targets):
probs = torch.sigmoid(logits)
probs = probs.view(probs.size(0), -1)
targets = targets.view(targets.size(0), -1)
inter = (probs * targets).sum(dim=1)
union = probs.sum(dim=1) + targets.sum(dim=1)
dice = (2 * inter + self.eps) / (union + self.eps)
return 1 - dice.mean()
bce = nn.BCEWithLogitsLoss()
dice = DiceLoss()
def total_loss(logits, mask, w_dice=1.0):
return bce(logits, mask) + w_dice * dice(logits, mask)
학습 코드 (Train / Eval)
이제 DataLoader에서 (imgA, imgB, mask)를 받아 모델을 학습합니다. 아래 코드는 AMP(자동 혼합정밀)를 포함한 “실전형 최소 학습 루프”입니다.
import torch
from torch.utils.data import DataLoader
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
# (이전 글) LEVIR Dataset을 (imgA, imgB, mask) 형태로 반환한다고 가정
# 예: return imgA, imgB, mask
# train_ds = LEVIRDataset(split="train", transforms=get_cd_transforms(train=True))
# val_ds = LEVIRDataset(split="val", transforms=get_cd_transforms(train=False))
# 예시 DataLoader
train_loader = DataLoader(train_ds, batch_size=8, shuffle=True, num_workers=4, pin_memory=True)
val_loader = DataLoader(val_ds, batch_size=8, shuffle=False, num_workers=4, pin_memory=True)
model = SiameseChangeNet(base=32).to(device)
optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4, weight_decay=1e-4)
scaler = torch.cuda.amp.GradScaler(enabled=True)
@torch.no_grad()
def calc_iou(logits, mask, thr=0.5, eps=1e-6):
pred = (torch.sigmoid(logits) >= thr).float()
pred = pred.view(pred.size(0), -1)
mask = mask.view(mask.size(0), -1)
inter = (pred * mask).sum(dim=1)
union = (pred + mask - pred*mask).sum(dim=1)
iou = (inter + eps) / (union + eps)
return iou.mean().item()
def train_one_epoch(epoch):
model.train()
loss_sum = 0.0
iou_sum = 0.0
for imgA, imgB, mask in train_loader:
imgA = imgA.to(device, non_blocking=True)
imgB = imgB.to(device, non_blocking=True)
mask = mask.to(device, non_blocking=True)
optimizer.zero_grad(set_to_none=True)
with torch.cuda.amp.autocast(enabled=True):
logits = model(imgA, imgB)
loss = total_loss(logits, mask, w_dice=1.0)
scaler.scale(loss).backward()
scaler.step(optimizer)
scaler.update()
loss_sum += loss.item()
iou_sum += calc_iou(logits.detach(), mask)
print(f"[Train][Epoch {epoch}] loss={loss_sum/len(train_loader):.4f} iou={iou_sum/len(train_loader):.4f}")
@torch.no_grad()
def validate(epoch):
model.eval()
loss_sum = 0.0
iou_sum = 0.0
for imgA, imgB, mask in val_loader:
imgA = imgA.to(device, non_blocking=True)
imgB = imgB.to(device, non_blocking=True)
mask = mask.to(device, non_blocking=True)
logits = model(imgA, imgB)
loss = total_loss(logits, mask, w_dice=1.0)
loss_sum += loss.item()
iou_sum += calc_iou(logits, mask)
print(f"[Val][Epoch {epoch}] loss={loss_sum/len(val_loader):.4f} iou={iou_sum/len(val_loader):.4f}")
best_iou = -1.0
for epoch in range(1, 21):
train_one_epoch(epoch)
validate(epoch)
# 간단한 best 저장 예시 (val iou 기준)
# 실제로는 validate()에서 iou를 반환받아 쓰는 것이 깔끔합니다.
학습 중에는 loss만 보지 말고 IoU를 같이 확인하는 것이 중요합니다. 변화 탐지는 픽셀 불균형 때문에 loss가 내려가도 결과가 별로인 경우가 종종 있습니다.
추론 및 결과 저장(예측 마스크)
학습이 끝나면 단일 샘플에 대해 예측 마스크를 저장해 결과를 확인할 수 있습니다. 아래는 (A/B/GT/Pred)에서 Pred 마스크를 png로 저장하는 예시입니다.
import numpy as np
import cv2
@torch.no_grad()
def save_pred_example(dataset, idx=0, out_path="pred_mask.png", thr=0.5):
model.eval()
imgA, imgB, mask = dataset[idx]
imgA = imgA.unsqueeze(0).to(device)
imgB = imgB.unsqueeze(0).to(device)
logits = model(imgA, imgB)
pred = (torch.sigmoid(logits) >= thr).float().squeeze(0).squeeze(0) # (H,W)
pred_np = (pred.cpu().numpy() * 255).astype(np.uint8)
cv2.imwrite(out_path, pred_np)
print("[Saved]", out_path)
# 예시
# save_pred_example(val_ds, idx=0, out_path="out_pred_000.png", thr=0.5)
마무리
이번 글에서는 LEVIR 데이터셋을 기준으로 Siamese 기반 변화 탐지 모델을 구성하고, BCE + Dice Loss 조합으로 학습하는 전체 과정을 살펴보았습니다. 핵심은 두 시점 입력(A/B)을 동일 인코더로 처리하고, 특징 차이를 이용해 변화 마스크를 복원하는 구조를 안정적으로 학습 루프에 연결하는 것입니다.
다음 글에서는 예측 결과를 좀 더 보기 좋게 시각화(Overlay)하고, IoU/F1을 제대로 측정하는 평가 코드까지 정리해 “학습 → 평가 → 개선” 흐름이 자연스럽게 이어지도록 구성해보겠습니다.
관련 내용
- [실전 예제/변화 탐지/PyTorch] 변화 탐지 튜토리얼: LEVIR 데이터셋으로 PyTorch 데이터셋 만들기
- [PyTorch] 맞춤형 데이터셋 만들기: torch.utils.data.Dataset() 사용 가이드
- [PyTorch] 다중 클래스 분류에서 필수: torch.nn.CrossEntropyLoss() 사용 가이드
'실전 예제, 프로젝트' 카테고리의 다른 글
| [실전 예제/인스턴스 분할/PyTorch] Mask R-CNN 모델 구성과 COCO 학습 (0) | 2026.01.14 |
|---|---|
| [실전 예제/객체 탐지/PyTorch] DOTA 객체 검출 모델 구성과 학습 (0) | 2026.01.14 |
| [실전 예제/객체 추적/PyTorch] Re-ID 기반 객체 추적 모델 구성과 학습 (0) | 2026.01.14 |
| [실전 예제/객체 추적/PyTorch] MOT 데이터셋으로 객체 추적 데이터셋 구성하기 (0) | 2025.04.27 |
| [실전 예제/리스트/파이썬] 리스트 요소에 같은 연산을 적용하는 6가지 방법 (0) | 2025.04.22 |