본문 바로가기
실전 예제, 프로젝트

[실전 예제/변화 탐지/PyTorch] Siamese 기반 변화 탐지 모델 구성과 학습

by First Adventure 2026. 1. 14.
반응형

Siamese 기반 변화 탐지(Change Detection)이란?

  변화 탐지(Change Detection)는 두 시점(A/B)의 이미지를 비교해 변화가 발생한 영역을 픽셀 단위로 분할(Segmentation)하는 컴퓨터 비전 태스크입니다. 단순히 “다르다/같다”를 판별하는 것이 아니라, 어디가 얼마나 변했는지를 마스크 형태로 예측하는 것이 핵심입니다.

  Siamese 기반 모델은 두 입력 이미지(A/B)를 같은 가중치(shared weights)를 가진 인코더로 각각 특징을 추출한 뒤, 특징 차이/결합을 통해 변화 영역을 복원합니다. 이번 글에서는 LEVIR 데이터셋을 기준으로 Siamese 구조 모델을 구성하고 학습하는 방법을 PyTorch로 정리합니다.

  • 예: 건물 신축/철거, 도로 확장 등 변화 영역 자동 탐지
  • 예: 재난 전/후 피해 지역 추정(변화 마스크 생성)

 

목표

  1. LEVIR (A/B + Mask) 데이터셋을 그대로 활용해 학습 파이프라인 구성
  2. Siamese Encoder + Decoder 기반 변화 탐지 모델 정의
  3. BCEWithLogits + Dice Loss 조합으로 학습 안정화
  4. 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() 사용 가이드
반응형