이미지 로딩 중...

Stable Diffusion 아키텍처 완벽 가이드 - 슬라이드 1/11
A

AI Generated

2025. 11. 8. · 3 Views

Stable Diffusion 아키텍처 완벽 가이드

Stable Diffusion의 핵심 아키텍처를 깊이 있게 분석합니다. VAE, U-Net, CLIP 텍스트 인코더의 역할부터 Latent Diffusion의 작동 원리까지, 실제 구현 코드와 함께 상세히 알아봅니다.


목차

  1. Latent Diffusion Model - 이미지를 압축된 공간에서 생성하는 핵심 기술
  2. U-Net Architecture - 노이즈를 예측하는 Diffusion의 두뇌
  3. CLIP Text Encoder - 텍스트를 이미지로 연결하는 다리
  4. VAE (Variational Autoencoder) - 이미지와 Latent Space의 번역가
  5. Cross-Attention Mechanism - 텍스트와 이미지를 결합하는 핵심
  6. Noise Scheduler - Diffusion 과정을 제어하는 시간표
  7. Classifier-Free Guidance - 프롬프트 충실도를 극대화하는 기법
  8. Timestep Embedding - 시간 정보를 네트워크에 주입하는 방법
  9. Self-Attention in U-Net - 전역적 일관성을 위한 장거리 의존성
  10. Conditioning Mechanisms - 다양한 조건을 주입하는 방법들

1. Latent Diffusion Model - 이미지를 압축된 공간에서 생성하는 핵심 기술

시작하며

여러분이 고해상도 이미지를 생성하려고 할 때, GPU 메모리가 부족해서 OOM(Out of Memory) 에러를 겪어본 적 있나요? 512x512 이미지 하나를 생성하는데도 수십 GB의 메모리가 필요하다면, 실용적인 AI 이미지 생성은 불가능할 것입니다.

이런 문제는 전통적인 Diffusion Model의 치명적인 약점이었습니다. 픽셀 공간(Pixel Space)에서 직접 작업하면 계산량이 이미지 해상도의 제곱에 비례해서 증가하기 때문입니다.

1024x1024 이미지는 512x512보다 4배나 많은 연산이 필요합니다. 바로 이럴 때 필요한 것이 Latent Diffusion Model입니다.

이미지를 저차원의 잠재 공간(Latent Space)으로 압축해서 처리하면, 동일한 품질을 유지하면서도 연산량을 획기적으로 줄일 수 있습니다.

개요

간단히 말해서, Latent Diffusion Model은 이미지를 압축된 잠재 공간에서 생성한 후 다시 원본 크기로 복원하는 기술입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, GPU 리소스는 한정되어 있고 비용이 매우 비쌉니다.

예를 들어, 하루에 수천 장의 이미지를 생성해야 하는 서비스를 운영한다면, 연산 효율성이 곧 비용 효율성으로 직결됩니다. Latent Diffusion은 메모리 사용량을 약 8배 이상 줄이면서도 이미지 품질은 거의 동일하게 유지합니다.

전통적인 Pixel-Space Diffusion에서는 512x512x3 크기의 텐서를 직접 처리했다면, 이제는 64x64x4 크기의 latent 표현만 처리하면 됩니다. 이는 데이터 크기를 약 48배 줄인 것입니다.

이 개념의 핵심 특징은 첫째, VAE를 통한 효율적인 압축, 둘째, 압축된 공간에서의 빠른 생성, 셋째, 고품질 복원입니다. 이러한 특징들이 Stable Diffusion을 소비자급 GPU에서도 실행 가능하게 만든 비결입니다.

코드 예제

import torch
from diffusers import AutoencoderKL

# VAE를 사용한 latent space 인코딩
vae = AutoencoderKL.from_pretrained("stabilityai/sd-vae-ft-mse")

# 원본 이미지 (1, 3, 512, 512)
image = torch.randn(1, 3, 512, 512)

# 이미지를 latent space로 압축 (1, 4, 64, 64)
# 8배 다운샘플링 + 채널 변환
with torch.no_grad():
    latent = vae.encode(image).latent_dist.sample()
    latent = latent * 0.18215  # 스케일링 팩터

# latent space에서 diffusion 수행 후 디코딩
decoded_image = vae.decode(latent / 0.18215).sample

print(f"원본 크기: {image.shape}")  # torch.Size([1, 3, 512, 512])
print(f"압축 크기: {latent.shape}")  # torch.Size([1, 4, 64, 64])

설명

이것이 하는 일: VAE 인코더로 이미지를 저차원 latent로 압축하고, 그 공간에서 diffusion을 수행한 후, VAE 디코더로 다시 고해상도 이미지를 복원합니다. 첫 번째로, VAE 인코더가 512x512x3 이미지를 64x64x4 latent 벡터로 압축합니다.

여기서 중요한 것은 단순 다운샘플링이 아니라 학습된 압축이라는 점입니다. VAE는 이미지의 중요한 의미적 정보는 보존하면서 불필요한 고주파 디테일은 제거하도록 훈련되었습니다.

0.18215라는 스케일링 팩터는 latent 분포를 정규화하여 diffusion 모델이 학습하기 쉽게 만듭니다. 그 다음으로, 압축된 64x64x4 공간에서 U-Net이 노이즈 제거 작업을 수행합니다.

원본 픽셀 공간에서 작업했다면 512x512x3 = 786,432개의 값을 처리해야 하지만, latent 공간에서는 64x64x4 = 16,384개만 처리하면 됩니다. 이는 약 48배의 효율성 향상을 의미합니다.

마지막으로, 깨끗해진 latent를 VAE 디코더에 통과시켜 512x512x3 고해상도 이미지로 복원합니다. 디코더는 압축 과정에서 손실된 고주파 디테일을 학습된 prior를 바탕으로 재구성합니다.

여러분이 이 코드를 사용하면 동일한 GPU에서 훨씬 큰 배치 사이즈로 이미지를 생성하거나, 더 높은 해상도를 처리할 수 있습니다. 실무에서는 메모리 효율성, 추론 속도 향상, 비용 절감이라는 세 가지 이점을 동시에 얻을 수 있습니다.

실전 팁

💡 VAE 모델을 선택할 때는 sd-vae-ft-mse 버전을 사용하세요. 기본 VAE보다 색상 재현성이 훨씬 좋아 실무에서 더 선호됩니다.

💡 latent에 곱하는 0.18215 스케일링 팩터를 빼먹으면 생성 품질이 크게 저하됩니다. 인코딩할 때 곱하고 디코딩할 때 나누는 것을 항상 페어로 기억하세요.

💡 메모리가 부족할 때는 vae.enable_slicing()을 호출하여 타일 단위로 처리하면 메모리 사용량을 더 줄일 수 있습니다.

💡 VAE는 한 번 로드하면 재사용하세요. 매번 새로 로드하면 초기화 시간이 낭비됩니다. 싱글톤 패턴으로 관리하는 것이 좋습니다.

💡 프로덕션 환경에서는 torch.no_grad()와 model.eval()을 반드시 사용하여 불필요한 gradient 계산을 방지하고 추론 속도를 높이세요.


2. U-Net Architecture - 노이즈를 예측하는 Diffusion의 두뇌

시작하며

여러분이 이미지에서 노이즈를 제거하려고 할 때, 단순히 블러 필터를 적용하면 디테일까지 함께 사라지는 문제를 겪어본 적 있나요? Diffusion 모델에서는 각 타임스텝마다 정확한 양의 노이즈만 제거해야 하는데, 이것이 생각보다 훨씬 어려운 작업입니다.

이런 문제는 네트워크가 이미지의 전역적 구조와 지역적 디테일을 동시에 이해해야 해서 발생합니다. 저해상도에서는 "이것이 고양이인지 강아지인지" 같은 큰 구조를 파악해야 하고, 고해상도에서는 "털의 질감"이나 "눈동자의 반사" 같은 미세한 디테일을 처리해야 합니다.

바로 이럴 때 필요한 것이 U-Net Architecture입니다. 다양한 해상도 레벨에서 정보를 추출하고 결합하여, 전역적 맥락과 지역적 디테일을 모두 고려한 정확한 노이즈 예측을 수행합니다.

개요

간단히 말해서, U-Net은 이미지를 점진적으로 다운샘플링했다가 다시 업샘플링하면서, 동일한 해상도 레벨 간에 skip connection을 통해 정보를 전달하는 구조입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, Stable Diffusion의 품질은 거의 전적으로 U-Net의 성능에 달려있습니다.

예를 들어, 사람 얼굴을 생성할 때 전체적인 얼굴 윤곽(저해상도)과 피부 모공이나 속눈썹(고해상도)을 동시에 정확하게 생성해야 자연스러운 결과물이 나옵니다. 기존의 일반 CNN에서는 한 가지 해상도에서만 특징을 추출했다면, 이제는 4-5단계의 서로 다른 해상도에서 동시에 특징을 추출하고 결합할 수 있습니다.

이 개념의 핵심 특징은 첫째, 인코더-디코더의 대칭 구조, 둘째, skip connection을 통한 정보 보존, 셋째, cross-attention을 통한 텍스트 조건 주입입니다. 이러한 특징들이 Stable Diffusion이 텍스트와 정확히 일치하는 고품질 이미지를 생성할 수 있게 만듭니다.

코드 예제

from diffusers import UNet2DConditionModel
import torch

# Stable Diffusion U-Net 로드
unet = UNet2DConditionModel.from_pretrained(
    "runwayml/stable-diffusion-v1-5",
    subfolder="unet"
)

# 입력: latent (1, 4, 64, 64), 텍스트 임베딩 (1, 77, 768), 타임스텝
latent_input = torch.randn(1, 4, 64, 64)
text_embeddings = torch.randn(1, 77, 768)
timestep = torch.tensor([500])

# U-Net으로 노이즈 예측
# Cross-attention을 통해 텍스트 정보 활용
with torch.no_grad():
    noise_pred = unet(
        latent_input,
        timestep,
        encoder_hidden_states=text_embeddings
    ).sample

print(f"입력 latent: {latent_input.shape}")  # (1, 4, 64, 64)
print(f"예측된 노이즈: {noise_pred.shape}")   # (1, 4, 64, 64)

설명

이것이 하는 일: U-Net은 현재 타임스텝의 noisy latent와 텍스트 임베딩을 입력받아, 해당 타임스텝에서 제거해야 할 노이즈의 양과 방향을 정확히 예측합니다. 첫 번째로, 인코더 부분에서 64x64 latent가 32x32, 16x16, 8x8로 점진적으로 다운샘플링됩니다.

각 단계에서 ResNet 블록과 Self-Attention이 적용되어 해당 해상도의 의미적 특징을 추출합니다. 예를 들어, 8x8 레벨에서는 "이미지 전체의 구도와 주요 객체의 배치" 같은 전역적 정보가 담기고, 64x64 레벨에서는 "객체의 경계선과 질감" 같은 지역적 정보가 담깁니다.

그 다음으로, 각 해상도 레벨에서 Cross-Attention 메커니즘이 텍스트 임베딩과 시각적 특징을 결합합니다. "a cat sitting on a red sofa"라는 프롬프트가 있다면, attention 메커니즘은 이미지의 어느 부분이 "cat"에 해당하고 어느 부분이 "red sofa"에 해당하는지 학습된 가중치로 매핑합니다.

encoder_hidden_states 파라미터로 전달되는 (1, 77, 768) 텐서가 바로 CLIP으로 인코딩된 텍스트 정보입니다. 디코더 부분에서는 8x8에서 시작해 16x16, 32x32, 64x64로 점진적으로 업샘플링됩니다.

여기서 핵심은 skip connection입니다. 예를 들어, 디코더의 32x32 레벨은 인코더의 32x32 레벨에서 추출된 특징과 concat되어 처리됩니다.

이를 통해 다운샘플링 과정에서 손실될 수 있는 디테일 정보를 복구할 수 있습니다. 여러분이 이 코드를 사용하면 각 diffusion 스텝마다 제거할 노이즈를 정확히 예측할 수 있습니다.

실무에서는 이 예측 정확도가 이미지 품질, 프롬프트 일치도, 생성 안정성을 모두 결정하는 가장 중요한 요소입니다.

실전 팁

💡 U-Net의 attention 레이어는 메모리를 많이 소비합니다. enable_attention_slicing()을 사용하면 attention을 여러 스텝으로 나눠 처리해 메모리를 절약할 수 있습니다.

💡 타임스텝 인코딩이 매우 중요합니다. 동일한 노이즈라도 timestep=50일 때와 timestep=500일 때 제거해야 할 양이 다르므로, 타임스텝을 정확히 전달해야 합니다.

💡 프로덕션에서는 U-Net을 half precision (float16)으로 실행하면 속도가 약 2배 빨라지고 메모리도 절반으로 줄어듭니다. unet.half()로 변환하세요.

💡 Cross-attention의 encoder_hidden_states 차원은 반드시 (batch, 77, 768)이어야 합니다. CLIP의 최대 토큰 길이가 77이기 때문입니다.

💡 여러 이미지를 배치로 생성할 때는 동일한 텍스트라도 배치 크기만큼 복제해서 전달해야 합니다. text_embeddings.repeat(batch_size, 1, 1)을 사용하세요.


3. CLIP Text Encoder - 텍스트를 이미지로 연결하는 다리

시작하며

여러분이 "a beautiful sunset over mountains"라는 텍스트를 AI에게 줬을 때, 컴퓨터는 이것을 어떻게 이해할까요? 단순히 단어의 나열이 아니라, "아름다운 석양의 색감", "산의 실루엣", "하늘의 그라데이션" 같은 시각적 개념과 연결되어야 제대로 된 이미지를 생성할 수 있습니다.

이런 문제는 텍스트와 이미지가 근본적으로 다른 모달리티라는 점에서 발생합니다. 텍스트는 이산적인 심볼의 시퀀스이고, 이미지는 연속적인 픽셀 값의 2D 배열입니다.

두 세계를 어떻게 연결할 것인가가 Text-to-Image 생성의 핵심 과제입니다. 바로 이럴 때 필요한 것이 CLIP Text Encoder입니다.

텍스트를 의미론적으로 풍부한 벡터 공간으로 매핑하여, U-Net이 텍스트의 의미를 시각적 특징과 결합할 수 있게 만듭니다.

개요

간단히 말해서, CLIP Text Encoder는 텍스트 프롬프트를 고차원 임베딩 벡터(77x768)로 변환하여, 각 단어의 의미와 문맥, 그리고 시각적 개념과의 관계를 인코딩하는 모델입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 프롬프트의 품질이 생성 이미지의 품질을 결정합니다.

예를 들어, "cat"이라는 단어를 단순히 one-hot 벡터로 표현하면 "고양이의 시각적 특징"에 대한 정보가 전혀 담기지 않지만, CLIP 임베딩에는 "털이 있다", "네 발로 걷는다", "귀가 뾰족하다" 같은 시각적 속성이 암묵적으로 인코딩되어 있습니다. 기존의 Word2Vec이나 BERT에서는 텍스트 간의 의미적 유사성만 학습했다면, 이제는 텍스트와 이미지 간의 의미적 유사성까지 학습하여 cross-modal 연결이 가능합니다.

이 개념의 핵심 특징은 첫째, 4억 개의 이미지-텍스트 쌍으로 사전 학습된 강력한 언어 이해, 둘째, 시각적 개념과 정렬된 임베딩 공간, 셋째, 최대 77 토큰까지 지원하는 긴 문맥 처리입니다. 이러한 특징들이 복잡하고 상세한 프롬프트도 정확히 이미지로 변환할 수 있게 만듭니다.

코드 예제

from transformers import CLIPTokenizer, CLIPTextModel
import torch

# CLIP Text Encoder 로드
tokenizer = CLIPTokenizer.from_pretrained("openai/clip-vit-large-patch14")
text_encoder = CLIPTextModel.from_pretrained("openai/clip-vit-large-patch14")

# 텍스트 프롬프트
prompt = "a professional photograph of a cat sitting on a red sofa, highly detailed"

# 토큰화: 텍스트를 정수 ID로 변환
tokens = tokenizer(
    prompt,
    padding="max_length",
    max_length=77,
    truncation=True,
    return_tensors="pt"
)

# 텍스트 인코딩: 토큰을 의미 벡터로 변환
with torch.no_grad():
    text_embeddings = text_encoder(tokens.input_ids)[0]

print(f"토큰 개수: {tokens.input_ids.shape}")     # (1, 77)
print(f"임베딩 shape: {text_embeddings.shape}")  # (1, 77, 768)

설명

이것이 하는 일: 텍스트 프롬프트를 받아서 토큰화하고, 각 토큰을 768차원의 의미론적 벡터로 변환한 후, Transformer 레이어를 통해 문맥 정보를 추가하여 최종 임베딩을 생성합니다. 첫 번째로, tokenizer가 프롬프트를 BPE(Byte Pair Encoding) 알고리즘으로 토큰화합니다.

"professional photograph"는 ["professional", "photograph"]로 나뉘고, 각각 vocab에서 정수 ID로 매핑됩니다. padding="max_length"로 설정하면 항상 77 토큰으로 맞춰지는데, 부족한 부분은 [PAD] 토큰으로 채워집니다.

이는 배치 처리를 위해 모든 시퀀스 길이를 동일하게 유지하기 위함입니다. 그 다음으로, text_encoder가 각 토큰 ID를 임베딩 테이블에서 조회하여 초기 768차원 벡터로 변환합니다.

그리고 12개의 Transformer 레이어를 통과시켜 문맥 정보를 추가합니다. 예를 들어, "red"라는 단어는 "red sofa"라는 문맥에서 "빨간색 소파"를 의미하지만, "red apple"이라면 "빨간 사과"를 의미합니다.

Self-attention 메커니즘이 이런 문맥 의존성을 포착합니다. 중요한 점은 CLIP이 contrastive learning으로 학습되었다는 것입니다.

4억 개의 이미지-텍스트 쌍으로 "정확히 매칭되는 이미지-텍스트 쌍은 유사하게, 그렇지 않은 쌍은 다르게" 만드는 방향으로 학습했기 때문에, 출력 임베딩이 시각적 개념과 자연스럽게 정렬되어 있습니다. 여러분이 이 코드를 사용하면 어떤 텍스트 프롬프트든 U-Net이 이해할 수 있는 형태로 변환할 수 있습니다.

실무에서는 프롬프트 엔지니어링의 기초가 되며, 어떤 단어 조합이 어떤 시각적 결과를 만드는지 이해하는 출발점입니다.

실전 팁

💡 77 토큰 제한을 넘는 긴 프롬프트는 자동으로 잘립니다. 중요한 키워드는 앞쪽에 배치하세요. 뒤쪽 토큰은 무시될 수 있습니다.

💡 쉼표로 구분된 키워드는 각각 독립적인 개념으로 인식됩니다. "cat, sitting, red sofa"처럼 명확히 분리하면 각 요소가 더 정확히 반영됩니다.

💡 형용사의 위치가 중요합니다. "a red beautiful cat"보다 "a beautiful red cat"이 더 자연스럽게 해석됩니다. 문법적으로 올바른 순서를 유지하세요.

💡 negative prompt도 동일한 방식으로 인코딩됩니다. 별도로 인코딩한 후 classifier-free guidance에서 사용됩니다.

💡 동일한 프롬프트를 반복 사용한다면 임베딩을 캐싱하세요. 매번 인코딩하는 것보다 10배 이상 빠릅니다.


4. VAE (Variational Autoencoder) - 이미지와 Latent Space의 번역가

시작하며

여러분이 고해상도 이미지를 효율적으로 저장하고 처리하려면 어떻게 해야 할까요? JPEG처럼 단순히 압축하면 정보 손실이 발생하고, 압축하지 않으면 메모리와 연산량이 폭발적으로 증가합니다.

Diffusion 모델에서는 이 딜레마가 더욱 심각합니다. 이런 문제는 픽셀 단위의 표현이 비효율적이기 때문입니다.

인접한 픽셀들은 높은 상관관계를 가지고 있어 중복 정보가 많고, 의미론적으로 중요한 정보와 중요하지 않은 노이즈가 섞여 있습니다. 예를 들어, 고양이 사진에서 "고양이가 있다"는 정보는 중요하지만 "배경의 먼지 입자"는 중요하지 않을 수 있습니다.

바로 이럴 때 필요한 것이 VAE입니다. 이미지를 확률적 잠재 표현으로 압축하여 중요한 의미적 정보는 보존하면서도, 데이터 크기를 획기적으로 줄이고 다시 고품질로 복원할 수 있게 합니다.

개요

간단히 말해서, VAE는 인코더로 이미지를 저차원 latent 분포로 압축하고, 디코더로 latent에서 다시 이미지를 복원하는 생성 모델이며, 잠재 공간이 연속적이고 매끄럽게 학습되도록 정규화됩니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, Stable Diffusion의 "Stable"은 바로 이 VAE 덕분입니다.

예를 들어, 512x512 RGB 이미지는 786,432개의 값이지만, VAE는 이를 16,384개로 압축합니다. 이는 Diffusion을 실시간에 가깝게 실행할 수 있게 만든 핵심 기술입니다.

기존의 Autoencoder에서는 deterministic한 압축을 수행했다면, 이제는 확률적 압축을 통해 latent 공간이 더 매끄럽고 보간 가능한(interpolatable) 구조를 갖게 됩니다. 이 개념의 핵심 특징은 첫째, KL divergence를 통한 latent 정규화, 둘째, 8배 다운샘플링을 통한 효율적 압축, 셋째, perceptual loss를 통한 고품질 복원입니다.

이러한 특징들이 Stable Diffusion이 빠르면서도 고품질 이미지를 생성할 수 있게 만듭니다.

코드 예제

from diffusers import AutoencoderKL
import torch

# Stable Diffusion VAE 로드
vae = AutoencoderKL.from_pretrained("stabilityai/sd-vae-ft-mse")

# 원본 이미지 (batch=1, channels=3, height=512, width=512)
image = torch.randn(1, 3, 512, 512)

# 인코딩: 이미지 -> latent 분포
with torch.no_grad():
    # 평균과 로그분산 추출
    latent_dist = vae.encode(image).latent_dist
    # 분포에서 샘플링 (reparameterization trick)
    latent = latent_dist.sample()
    # 스케일링 (Stable Diffusion 학습 때 사용된 팩터)
    latent = latent * 0.18215

    # 디코딩: latent -> 이미지
    reconstructed = vae.decode(latent / 0.18215).sample

print(f"원본: {image.shape}")              # (1, 3, 512, 512)
print(f"Latent: {latent.shape}")           # (1, 4, 64, 64)
print(f"복원: {reconstructed.shape}")       # (1, 3, 512, 512)
print(f"압축률: {image.numel() / latent.numel():.1f}x")  # 48.0x

설명

이것이 하는 일: 이미지를 확률적 잠재 표현으로 인코딩하고 다시 디코딩하는 과정에서, KL divergence 정규화를 통해 latent 공간이 표준 정규분포에 가깝게 만들어 diffusion이 작업하기 쉬운 환경을 조성합니다. 첫 번째로, VAE 인코더가 512x512x3 이미지를 여러 Conv 레이어로 처리하여 64x64x8로 압축합니다.

그리고 이 8개 채널을 평균(mean) 4개와 로그분산(logvar) 4개로 분리합니다. 왜 분산이 아니라 로그분산을 사용할까요?

분산은 항상 양수여야 하는데, 네트워크 출력을 양수로 제한하면 학습이 불안정해집니다. 로그분산을 사용하면 출력이 임의의 실수 범위를 가질 수 있어 학습이 안정적입니다.

그 다음으로, reparameterization trick을 적용하여 샘플링합니다. z = mean + std * epsilon 공식으로, epsilon은 표준 정규분포에서 샘플링한 랜덤 노이즈입니다.

이 트릭 덕분에 확률적 샘플링 과정에서도 역전파가 가능해집니다. 학습 시에는 KL divergence loss가 추가되어 latent 분포가 N(0,1)에 가까워지도록 유도합니다.

이렇게 정규화된 latent 공간에서 diffusion 모델이 더 쉽게 학습할 수 있습니다. 디코더는 4x64x64 latent를 받아 여러 ConvTranspose 레이어로 업샘플링하여 3x512x512 이미지로 복원합니다.

학습 시 단순 L2 loss가 아니라 perceptual loss (LPIPS)를 사용하여, 픽셀 단위가 아닌 인간의 지각적 유사성을 최적화합니다. 그래서 약간의 픽셀 차이가 있어도 시각적으로 거의 동일한 이미지를 복원할 수 있습니다.

여러분이 이 코드를 사용하면 대용량 이미지를 효율적으로 처리할 수 있습니다. 실무에서는 메모리 절약, 처리 속도 향상, 그리고 의미론적으로 구조화된 latent 공간 덕분에 더 제어 가능한 이미지 생성이 가능합니다.

실전 팁

💡 sd-vae-ft-mse는 sd-vae-ft-ema보다 평균 제곱 오차가 더 낮아 색상 재현이 정확합니다. 특히 인물 사진에서 피부톤이 더 자연스럽습니다.

💡 latent 샘플링 시 sample() 대신 mode()를 사용하면 분포의 평균값을 사용해 deterministic한 결과를 얻을 수 있습니다. 재현성이 중요할 때 유용합니다.

💡 0.18215 스케일링 팩터는 Stable Diffusion 학습 시 사용된 값입니다. 이 값을 바꾸면 생성 품질이 크게 저하되므로 반드시 동일하게 유지하세요.

💡 고해상도 이미지 처리 시 vae.enable_tiling()을 사용하면 타일 단위로 나눠 처리해 메모리 부족 문제를 방지할 수 있습니다.

💡 VAE를 fine-tuning할 때는 discriminator를 함께 사용하는 것이 좋습니다. Generator만 학습하면 블러한 결과물이 나올 수 있습니다.


5. Cross-Attention Mechanism - 텍스트와 이미지를 결합하는 핵심

시작하며

여러분이 "a blue bird on a green tree"라는 프롬프트로 이미지를 생성한다고 상상해보세요. U-Net이 이미지의 어떤 부분이 "blue bird"에 해당하고 어떤 부분이 "green tree"에 해당하는지 어떻게 알 수 있을까요?

단순히 텍스트 임베딩을 concat하거나 더하는 것만으로는 이런 세밀한 조건 제어가 불가능합니다. 이런 문제는 공간적 위치와 의미적 개념을 연결해야 하기 때문에 발생합니다.

이미지의 왼쪽 상단 영역은 "bird"와 관련되어야 하고, 배경은 "tree"와 관련되어야 하는 식으로, pixel-level에서 텍스트 토큰과의 대응 관계가 필요합니다. 바로 이럴 때 필요한 것이 Cross-Attention Mechanism입니다.

이미지의 각 공간적 위치가 텍스트의 어떤 단어에 주목(attend)해야 하는지 동적으로 계산하여, 프롬프트의 각 요소를 정확한 위치에 반영합니다.

개요

간단히 말해서, Cross-Attention은 Query를 이미지 특징에서 가져오고 Key와 Value를 텍스트 임베딩에서 가져와, 이미지의 각 픽셀 위치가 어떤 텍스트 토큰과 관련이 있는지 attention 가중치로 계산하는 메커니즘입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 복잡한 프롬프트의 각 요소를 정확히 반영하는 것이 Text-to-Image 모델의 핵심 능력입니다.

예를 들어, "a red car next to a blue house"에서 "red"는 car에만 적용되고 house에는 적용되지 않아야 합니다. Cross-attention이 없다면 모든 색상이 전체 이미지에 섞여서 적용될 것입니다.

기존의 단순 conditioning(FiLM, AdaIN)에서는 전역적으로만 조건을 적용했다면, 이제는 각 공간적 위치마다 서로 다른 텍스트 정보를 선택적으로 활용할 수 있습니다. 이 개념의 핵심 특징은 첫째, Query-Key-Value의 비대칭 구조, 둘째, 소프트맥스를 통한 가중치 정규화, 셋째, Multi-Head로 다양한 관점 학습입니다.

이러한 특징들이 Stable Diffusion이 프롬프트를 정확히 이해하고 반영할 수 있게 만듭니다.

코드 예제

import torch
import torch.nn as nn

class CrossAttention(nn.Module):
    def __init__(self, dim=320, context_dim=768, num_heads=8):
        super().__init__()
        self.num_heads = num_heads
        self.head_dim = dim // num_heads

        # Query는 이미지 특징에서, Key/Value는 텍스트에서
        self.to_q = nn.Linear(dim, dim, bias=False)
        self.to_k = nn.Linear(context_dim, dim, bias=False)
        self.to_v = nn.Linear(context_dim, dim, bias=False)
        self.to_out = nn.Linear(dim, dim)

    def forward(self, x, context):
        # x: 이미지 특징 (batch, 4096, 320) - 64x64 공간
        # context: 텍스트 임베딩 (batch, 77, 768)

        q = self.to_q(x)  # (batch, 4096, 320)
        k = self.to_k(context)  # (batch, 77, 320)
        v = self.to_v(context)  # (batch, 77, 320)

        # Multi-head로 분할
        q = q.reshape(q.shape[0], q.shape[1], self.num_heads, self.head_dim)
        k = k.reshape(k.shape[0], k.shape[1], self.num_heads, self.head_dim)

        # Attention 계산: 각 이미지 위치가 어떤 텍스트에 주목할지
        attn = torch.einsum('bqhd,bkhd->bhqk', q, k) / (self.head_dim ** 0.5)
        attn = attn.softmax(dim=-1)  # (batch, heads, 4096, 77)

        # Attention 가중치로 Value 집계
        out = torch.einsum('bhqk,bkhd->bqhd', attn, v.reshape(v.shape[0], v.shape[1], self.num_heads, self.head_dim))
        out = out.reshape(out.shape[0], out.shape[1], -1)

        return self.to_out(out)

설명

이것이 하는 일: 이미지의 각 64x64=4096개 공간 위치마다, 77개 텍스트 토큰 중 어떤 것이 가장 관련 있는지 attention 가중치를 계산하여, 해당 텍스트 정보를 가중합으로 결합합니다. 첫 번째로, Linear projection을 통해 Query, Key, Value를 생성합니다.

핵심은 Query는 이미지 특징 x에서 만들고, Key와 Value는 텍스트 context에서 만든다는 점입니다. 이것이 Self-Attention과의 차이입니다.

Self-Attention이라면 모두 같은 입력에서 만들겠지만, Cross-Attention은 서로 다른 모달리티를 연결합니다. Context_dim=768은 CLIP 텍스트 인코더의 출력 차원이고, 이를 dim=320으로 프로젝션하여 U-Net의 특징 차원과 일치시킵니다.

그 다음으로, Multi-Head Attention을 위해 320차원을 8개 head로 나눕니다 (각 head는 40차원). 왜 Multi-Head일까요?

하나의 attention만 사용하면 한 가지 관점에서만 관계를 포착하지만, 여러 head를 사용하면 "색상 관계", "위치 관계", "객체 관계" 등 다양한 관점을 동시에 학습할 수 있습니다. Attention score 계산 시 einsum('bqhd,bkhd->bhqk')는 각 Query(이미지 위치)와 각 Key(텍스트 토큰) 간의 내적을 계산합니다.

결과는 (batch, 8 heads, 4096 pixels, 77 tokens) shape의 attention map입니다. 예를 들어, attn[0,0,100,5]는 "첫 번째 샘플, 첫 번째 head, 100번째 픽셀 위치가 5번째 텍스트 토큰에 얼마나 주목하는지"를 나타냅니다.

Head_dim으로 나누는 것은 내적 값이 너무 커지지 않도록 스케일링하는 것입니다. Softmax로 정규화하면 각 픽셀 위치에서 77개 토큰에 대한 가중치 합이 1이 됩니다.

그리고 이 가중치로 Value를 가중합하면, 각 픽셀 위치는 자신과 관련 있는 텍스트 정보만 선택적으로 가져옵니다. 예를 들어, 이미지의 왼쪽 영역이 "red car"에 높은 attention을 주고, 오른쪽 영역이 "blue house"에 높은 attention을 주는 식입니다.

여러분이 이 코드를 사용하면 프롬프트의 각 단어가 이미지의 정확한 위치에 반영되도록 제어할 수 있습니다. 실무에서는 이 메커니즘을 통해 복잡한 composition, 정확한 색상 배치, 올바른 객체 관계를 구현할 수 있습니다.

실전 팁

💡 Attention map을 시각화하면 모델이 프롬프트를 어떻게 해석하는지 이해할 수 있습니다. attn 텐서를 저장해서 heatmap으로 그려보세요.

💡 Head 개수가 많을수록 다양한 관점을 학습하지만 메모리도 많이 사용합니다. 8이 일반적으로 좋은 균형점입니다.

💡 스케일링 팩터 1/sqrt(head_dim)을 빼먹으면 softmax 입력 값이 너무 커져서 attention이 특정 토큰에만 집중되는 문제가 발생합니다.

💡 긴 프롬프트에서는 중요한 키워드가 높은 attention을 받도록 prompt weighting 기법을 사용할 수 있습니다. (keyword:1.5) 같은 문법으로 가중치를 조정합니다.

💡 Flash Attention을 구현하면 메모리 효율성을 크게 높일 수 있습니다. 특히 고해상도 생성 시 필수적입니다.


6. Noise Scheduler - Diffusion 과정을 제어하는 시간표

시작하며

여러분이 이미지 생성을 시작할 때, 순수한 노이즈에서 시작해서 점진적으로 깨끗한 이미지로 만들어가는 과정을 상상해보세요. 이때 각 단계에서 얼마나 많은 노이즈를 제거해야 할까요?

너무 많이 제거하면 디테일이 사라지고, 너무 적게 제거하면 노이즈가 남아있을 것입니다. 이런 문제는 Diffusion이 확률적 과정이기 때문에 발생합니다.

1000개의 타임스텝 각각에서 정확한 양의 노이즈를 추가하거나 제거하는 스케줄이 필요합니다. 잘못된 스케줄을 사용하면 학습과 생성이 모두 불안정해집니다.

바로 이럴 때 필요한 것이 Noise Scheduler입니다. 각 타임스텝에서 노이즈의 양을 결정하는 beta 스케줄과, 효율적인 샘플링을 위한 alpha 계산을 통해 안정적이고 고품질의 생성 과정을 보장합니다.

개요

간단히 말해서, Noise Scheduler는 각 타임스텝 t에서 노이즈 강도를 결정하는 beta_t와, 누적 노이즈를 계산하는 alpha_bar_t를 관리하여, forward diffusion(노이즈 추가)과 reverse diffusion(노이즈 제거)을 제어하는 알고리즘입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 스케줄러의 선택이 생성 속도와 품질을 직접적으로 결정합니다.

예를 들어, DDPM은 1000 스텝이 필요하지만 DDIM은 50 스텝으로 유사한 품질을 달성하고, DPM-Solver++는 20 스텝으로도 충분합니다. 실시간 서비스에서는 스텝 수가 곧 응답 시간이므로 매우 중요합니다.

기존의 단순 linear schedule에서는 초반과 후반의 노이즈 변화가 균등했다면, 이제는 cosine schedule처럼 초반에는 천천히, 후반에는 급격히 변하는 비선형 스케줄로 품질을 향상시킬 수 있습니다. 이 개념의 핵심 특징은 첫째, variance preserving formulation으로 신호 크기 유지, 둘째, 효율적인 샘플링을 위한 alpha_bar 사전 계산, 셋째, 다양한 샘플러(DDPM, DDIM, DPM) 지원입니다.

이러한 특징들이 Stable Diffusion이 빠르고 안정적으로 작동하게 만듭니다.

코드 예제

from diffusers import DDPMScheduler, DDIMScheduler
import torch

# DDPM 스케줄러 초기화
scheduler = DDPMScheduler(
    num_train_timesteps=1000,
    beta_start=0.00085,
    beta_end=0.012,
    beta_schedule="scaled_linear"
)

# 초기 노이즈 (pure random noise)
latent = torch.randn(1, 4, 64, 64)

# 특정 타임스텝(t=500)에 노이즈 추가 (training 시 사용)
timestep = torch.tensor([500])
noise = torch.randn_like(latent)
noisy_latent = scheduler.add_noise(latent, noise, timestep)

# 타임스텝별 노이즈 강도 확인
print(f"Beta at t=500: {scheduler.betas[500]:.6f}")
print(f"Alpha_bar at t=500: {scheduler.alphas_cumprod[500]:.6f}")

# DDIM으로 변경하면 50 스텝으로 동일 품질
ddim_scheduler = DDIMScheduler.from_config(scheduler.config)
ddim_scheduler.set_timesteps(50)  # 1000 -> 50 스텝으로 축소

설명

이것이 하는 일: Forward diffusion에서는 alpha_bar를 사용해 한 번에 임의의 타임스텝으로 노이즈를 추가하고, reverse diffusion에서는 beta를 사용해 점진적으로 노이즈를 제거하는 과정을 수학적으로 정확하게 제어합니다. 첫 번째로, Beta 스케줄을 정의합니다.

Beta_t는 타임스텝 t에서 추가되는 노이즈의 분산입니다. "scaled_linear" 스케줄은 beta_start=0.00085에서 beta_end=0.012로 선형적으로 증가하는데, 이는 초반에는 작은 노이즈를, 후반에는 큰 노이즈를 추가한다는 의미입니다.

왜 이렇게 할까요? 초반(t가 큰 경우)에는 이미 노이즈가 많아서 조금만 더 추가해도 충분하고, 후반(t가 작은 경우)에는 깨끗한 이미지에 가까워서 더 세밀한 제어가 필요하기 때문입니다.

그 다음으로, Alpha 계산을 수행합니다. Alpha_t = 1 - beta_t이고, alpha_bar_t는 alpha의 누적 곱입니다.

이 alpha_bar가 핵심인데, 이것을 사용하면 x_0(원본 이미지)에서 x_t(노이즈 섞인 이미지)로 한 번에 점프할 수 있습니다. 공식은 x_t = sqrt(alpha_bar_t) * x_0 + sqrt(1 - alpha_bar_t) * noise입니다.

이 variance preserving formulation 덕분에 신호의 크기가 항상 일정하게 유지되어 학습이 안정적입니다. Add_noise 함수는 이 공식을 구현한 것입니다.

예를 들어, t=500일 때 alpha_bar가 약 0.3이라면, 원본 신호의 sqrt(0.3)≈0.55만 유지하고 나머지 sqrt(0.7)≈0.84 만큼 노이즈를 추가합니다. 타임스텝이 증가할수록 alpha_bar가 감소하여 노이즈 비율이 증가합니다.

DDIM 스케줄러로 변경하면 1000 스텝 대신 50 스텝만 사용할 수 있습니다. DDIM은 deterministic sampling을 사용하여, 중간 타임스텝을 건너뛰어도 일관된 결과를 보장합니다.

Set_timesteps(50)을 호출하면 1000개 중 50개를 균등하게 선택합니다 (예: 0, 20, 40, ..., 980). 여러분이 이 코드를 사용하면 생성 속도와 품질의 트레이드오프를 제어할 수 있습니다.

실무에서는 프로토타이핑 시 빠른 DDIM 50스텝을 사용하고, 최종 결과물은 DPM-Solver++ 20스텝으로 품질과 속도를 모두 확보하는 전략을 많이 사용합니다.

실전 팁

💡 Beta 스케줄 선택이 중요합니다. Cosine schedule은 linear보다 초반 노이즈가 적어 디테일 보존에 유리하고, 특히 고해상도 이미지에 효과적입니다.

💡 Inference 시에는 항상 scheduler.set_timesteps()로 스텝 수를 명시적으로 설정하세요. 기본값은 학습 시 타임스텝(1000)이라 너무 느립니다.

💡 동일한 seed라도 스케줄러가 다르면 결과가 완전히 달라집니다. DDPM과 DDIM은 샘플링 방식이 다르기 때문입니다.

💡 DPM-Solver++와 UniPC 같은 최신 스케줄러는 10-20 스텝으로도 DDIM 50스텝과 유사한 품질을 달성합니다. 프로덕션에서는 이들을 우선 고려하세요.

💡 Guidance scale과 스케줄러는 독립적입니다. 높은 guidance scale(7-10)은 프롬프트 충실도를 높이지만, 색상 과포화를 유발할 수 있으니 스케줄러와 함께 튜닝하세요.


7. Classifier-Free Guidance - 프롬프트 충실도를 극대화하는 기법

시작하며

여러분이 "a highly detailed portrait of a cat"라는 프롬프트로 이미지를 생성했는데, 결과가 흐릿하고 프롬프트와 잘 맞지 않는 경험을 해본 적 있나요? Diffusion 모델은 기본적으로 확률적 생성을 하기 때문에, 텍스트 조건을 따르는 정도가 약할 수 있습니다.

이런 문제는 조건부 생성과 무조건부 생성의 균형을 어떻게 맞추느냐에서 발생합니다. 조건(텍스트)을 너무 약하게 주면 프롬프트를 무시하고, 너무 강하게 주면 artifact가 생깁니다.

전통적인 classifier guidance는 별도의 classifier 모델이 필요해서 복잡했습니다. 바로 이럴 때 필요한 것이 Classifier-Free Guidance입니다.

조건부 예측과 무조건부 예측의 차이를 증폭시켜, 별도의 classifier 없이도 텍스트 프롬프트에 훨씬 더 충실한 이미지를 생성할 수 있습니다.

개요

간단히 말해서, Classifier-Free Guidance는 텍스트 조건이 있을 때와 없을 때의 노이즈 예측 차이를 계산하고, 그 차이를 guidance scale만큼 증폭하여 최종 노이즈 예측을 만드는 기법입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 프롬프트 충실도는 사용자 만족도와 직결됩니다.

예를 들어, "blue eyes"라고 명시했는데 갈색 눈이 나온다면 재생성해야 하고, 이는 시간과 비용 낭비입니다. Guidance scale을 7-8 정도로 설정하면 프롬프트 일치율이 크게 향상됩니다.

기존의 Classifier Guidance에서는 별도의 image classifier를 학습하고 그 gradient를 사용했다면, 이제는 diffusion 모델 자체만으로 조건 강도를 제어할 수 있어 훨씬 간단하고 효율적입니다. 이 개념의 핵심 특징은 첫째, 학습 시 10-20% 확률로 텍스트를 빈 문자열로 치환하여 무조건부 생성 학습, 둘째, 추론 시 조건부와 무조건부 예측의 선형 결합, 셋째, guidance scale로 조건 강도 동적 조절입니다.

이러한 특징들이 Stable Diffusion이 프롬프트를 정확히 반영하면서도 유연하게 생성할 수 있게 만듭니다.

코드 예제

import torch

# U-Net으로 노이즈 예측 (간소화된 예시)
def predict_noise(unet, latent, timestep, text_embeddings, guidance_scale=7.5):
    # 배치 확장: [조건부, 무조건부]
    latent_input = torch.cat([latent] * 2)

    # 텍스트 임베딩: [조건부(프롬프트), 무조건부(빈 문자열)]
    # text_embeddings[0]: "a cat on a sofa" 임베딩
    # text_embeddings[1]: "" 빈 프롬프트 임베딩

    # 한 번의 forward pass로 두 예측 동시 계산
    with torch.no_grad():
        noise_pred = unet(
            latent_input,
            timestep,
            encoder_hidden_states=text_embeddings
        ).sample

    # 조건부와 무조건부 예측 분리
    noise_pred_text, noise_pred_uncond = noise_pred.chunk(2)

    # Classifier-Free Guidance 공식 적용
    # 무조건부 예측 + guidance_scale * (조건부 - 무조건부)
    noise_pred = noise_pred_uncond + guidance_scale * (
        noise_pred_text - noise_pred_uncond
    )

    return noise_pred

# 사용 예시
latent = torch.randn(1, 4, 64, 64)
text_emb_cond = torch.randn(1, 77, 768)  # 프롬프트 임베딩
text_emb_uncond = torch.randn(1, 77, 768)  # 빈 문자열 임베딩
text_embeddings = torch.cat([text_emb_cond, text_emb_uncond])

# guidance_scale=7.5: 조건 차이를 7.5배 증폭
noise = predict_noise(None, latent, timestep=500,
                     text_embeddings=text_embeddings,
                     guidance_scale=7.5)

설명

이것이 하는 일: 동일한 latent에 대해 텍스트 조건이 있을 때와 없을 때의 노이즈 예측을 각각 계산하고, 그 차이를 증폭하여 텍스트 조건의 영향력을 강화합니다. 첫 번째로, 배치 확장을 수행합니다.

Latent를 2번 복제하여 [조건부, 무조건부] 배치를 만듭니다. 텍스트 임베딩도 마찬가지로 [프롬프트 임베딩, 빈 문자열 임베딩]을 concat합니다.

빈 문자열 임베딩은 어떻게 만들까요? CLIP tokenizer에 ""를 입력하면 [BOS] + [PAD]*75 + [EOS] 형태의 토큰 시퀀스가 나오고, 이를 text encoder에 통과시킵니다.

이것이 "아무 조건 없는" 상태를 나타냅니다. 그 다음으로, U-Net forward pass를 한 번만 수행합니다.

배치 크기가 2이므로 한 번의 연산으로 조건부와 무조건부 예측을 동시에 얻을 수 있어 효율적입니다. Noise_pred의 shape은 (2, 4, 64, 64)이고, chunk(2)로 분리하면 각각 (1, 4, 64, 64)가 됩니다.

핵심 공식은 noise_pred = noise_uncond + guidance_scale * (noise_text - noise_uncond)입니다. 이를 재정렬하면 noise_pred = (1 - guidance_scale) * noise_uncond + guidance_scale * noise_text가 됩니다.

Guidance_scale=7.5라면 조건부 예측에 7.5배, 무조건부 예측에 -6.5배 가중치를 주는 것입니다. 이렇게 하면 "조건이 있을 때와 없을 때의 차이"가 7.5배로 증폭되어, 텍스트 조건의 영향력이 크게 강화됩니다.

Guidance_scale의 효과를 보면, 1.0일 때는 조건부 예측만 사용(guidance 없음), 7.5일 때는 적절한 프롬프트 충실도, 15.0처럼 높으면 프롬프트는 정확히 따르지만 색상 과포화와 artifact 발생, 0일 때는 무조건부 생성(프롬프트 무시)입니다. 여러분이 이 코드를 사용하면 프롬프트 충실도를 자유롭게 제어할 수 있습니다.

실무에서는 일반적으로 7-8을 사용하고, 창의적 변형을 원하면 4-5, 매우 정확한 재현을 원하면 10-12를 사용합니다.

실전 팁

💡 Guidance scale은 생성 품질의 가장 중요한 하이퍼파라미터입니다. 7.5가 기본값이지만, 프롬프트 복잡도에 따라 조정하세요.

💡 Negative prompt도 동일한 메커니즘으로 작동합니다. 무조건부 대신 negative prompt 임베딩을 사용하면 원하지 않는 요소를 제거할 수 있습니다.

💡 배치 처리 시 메모리가 2배 소요됩니다. 조건부/무조건부를 동시에 처리하기 때문입니다. GPU 메모리가 부족하면 sequential로 처리하세요.

💡 매우 긴 프롬프트에서는 guidance scale을 약간 낮추는 것이 좋습니다. 너무 많은 조건이 강하게 작용하면 충돌할 수 있습니다.

💡 동일한 seed와 프롬프트라도 guidance scale만 바꾸면 결과가 크게 달라집니다. 여러 값을 테스트하여 최적값을 찾으세요.


8. Timestep Embedding - 시간 정보를 네트워크에 주입하는 방법

시작하며

여러분이 U-Net에게 "지금이 diffusion의 어느 단계인지" 알려주지 않으면 어떻게 될까요? 타임스텝 500에서 제거할 노이즈와 타임스텝 50에서 제거할 노이즈는 완전히 다른데, 이 정보 없이는 올바른 예측이 불가능합니다.

이런 문제는 동일한 noisy latent라도 현재 타임스텝에 따라 다르게 처리해야 하기 때문에 발생합니다. 예를 들어, t=900에서는 대부분 노이즈이므로 대략적인 구조만 잡으면 되지만, t=100에서는 거의 깨끗한 상태이므로 미세한 디테일을 조정해야 합니다.

바로 이럴 때 필요한 것이 Timestep Embedding입니다. 타임스텝을 고차원 벡터로 인코딩하여 U-Net의 모든 레이어에 주입하면, 네트워크가 현재 diffusion 진행 상황을 인식하고 적절한 노이즈 예측을 수행할 수 있습니다.

개요

간단히 말해서, Timestep Embedding은 스칼라 타임스텝 값을 sinusoidal encoding으로 고차원 벡터로 변환하고, MLP를 통해 처리한 후 U-Net의 각 ResNet 블록에 더하거나 스케일/시프트 파라미터로 사용하는 기법입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 타임스텝 정보가 없으면 U-Net은 모든 단계에서 동일한 방식으로 작동하게 됩니다.

예를 들어, 초반 단계에서는 전역적 구조를 형성하는 데 집중해야 하고, 후반 단계에서는 지역적 디테일을 다듬는 데 집중해야 하는데, timestep embedding이 이런 적응적 행동을 가능하게 합니다. 기존의 단순 스칼라 입력으로 타임스텝을 전달했다면, 이제는 고차원 임베딩으로 변환하여 네트워크가 타임스텝의 미묘한 차이까지 학습할 수 있게 되었습니다.

이 개념의 핵심 특징은 첫째, Transformer의 positional encoding과 유사한 sinusoidal 함수 사용, 둘째, MLP를 통한 비선형 변환으로 표현력 증가, 셋째, AdaGN(Adaptive Group Normalization)을 통한 조건 주입입니다. 이러한 특징들이 U-Net이 diffusion의 각 단계를 정확히 인식하고 적응하게 만듭니다.

코드 예제

import torch
import torch.nn as nn
import math

class TimestepEmbedding(nn.Module):
    def __init__(self, dim=320, max_period=10000):
        super().__init__()
        self.dim = dim
        self.max_period = max_period

        # Sinusoidal embedding 후 MLP로 변환
        self.mlp = nn.Sequential(
            nn.Linear(dim, dim * 4),
            nn.SiLU(),
            nn.Linear(dim * 4, dim * 4),
        )

    def forward(self, timesteps):
        # timesteps: (batch,) - 예: [500, 500, 500]

        # Sinusoidal encoding
        half_dim = self.dim // 2
        freqs = torch.exp(
            -math.log(self.max_period) *
            torch.arange(half_dim, device=timesteps.device) / half_dim
        )
        args = timesteps[:, None].float() * freqs[None]
        embedding = torch.cat([torch.cos(args), torch.sin(args)], dim=-1)

        # MLP로 비선형 변환
        embedding = self.mlp(embedding)  # (batch, 1280)

        return embedding

# 사용 예시
timestep_emb = TimestepEmbedding(dim=320)
timesteps = torch.tensor([500, 250, 100])  # 배치 크기 3
emb = timestep_emb(timesteps)

print(f"입력 타임스텝: {timesteps}")
print(f"임베딩 shape: {emb.shape}")  # (3, 1280)

설명

이것이 하는 일: 타임스텝을 위치 인코딩 방식으로 임베딩하여 주파수 스펙트럼의 다양한 스케일로 표현하고, 이를 U-Net의 각 레이어에 주입하여 단계별 적응적 처리를 가능하게 합니다. 첫 번째로, Sinusoidal encoding을 계산합니다.

왜 sin/cos 함수를 사용할까요? 타임스텝은 순서가 있는 연속 값이므로, 인접한 타임스텝은 유사한 임베딩을 가져야 합니다.

Sin/cos 함수는 매끄럽게 변하므로 이 특성을 만족합니다. Freqs는 기하급수적으로 감소하는 주파수 계열을 만듭니다: [1, 1/10000^(1/160), 1/10000^(2/160), ..., 1/10000].

낮은 주파수는 긴 주기를 표현하고, 높은 주파수는 짧은 주기를 표현하여 다양한 시간 스케일을 포착합니다. 그 다음으로, timesteps[:, None]으로 shape을 (batch, 1)로 만들고, freqs[None]와 브로드캐스팅으로 곱하면 (batch, half_dim)이 됩니다.

각 타임스텝 값에 모든 주파수를 곱한 것입니다. 그리고 cos와 sin을 각각 계산해 concat하면 (batch, dim)이 됩니다.

예를 들어, t=500일 때 낮은 주파수 성분은 천천히 변하는 값을, 높은 주파수 성분은 빠르게 변하는 값을 만들어 타임스텝을 다층적으로 표현합니다. MLP는 sinusoidal embedding을 비선형 변환합니다.

Linear(320, 1280) -> SiLU -> Linear(1280, 1280) 구조로, 단순한 삼각함수 조합을 더 풍부한 표현으로 확장합니다. SiLU(Sigmoid Linear Unit)는 smooth한 활성화 함수로 gradient flow가 좋아 학습이 안정적입니다.

이 임베딩은 U-Net의 ResNet 블록에서 AdaGN(Adaptive Group Normalization)으로 주입됩니다. 정규화 후 scale과 shift 파라미터를 timestep embedding에서 예측하여 적용합니다: normalized_feature * (1 + scale) + shift.

이를 통해 타임스텝에 따라 feature의 분포를 동적으로 조정할 수 있습니다. 여러분이 이 코드를 사용하면 U-Net이 각 diffusion 단계를 명확히 구분하여 처리할 수 있습니다.

실무에서는 이 메커니즘이 안정적인 학습과 고품질 생성의 핵심 요소입니다.

실전 팁

💡 Max_period=10000은 Transformer의 positional encoding과 동일한 값입니다. 이는 최대 10000 타임스텝까지 고유하게 인코딩할 수 있음을 의미합니다.

💡 Sinusoidal embedding은 학습 가능한 파라미터가 없어 일반화가 좋습니다. 학습 중 보지 못한 타임스텝에도 잘 작동합니다.

💡 MLP의 hidden dimension은 보통 입력의 4배입니다. 이는 충분한 표현력을 제공하면서도 과적합을 방지하는 경험적 균형점입니다.

💡 Timestep embedding은 메모리를 거의 사용하지 않습니다. (batch, 1280) 크기로 매우 작아서 병목이 되지 않습니다.

💡 Custom scheduler를 만들 때는 timestep 범위를 일관되게 유지하세요. [0, 1000] 범위로 학습했다면 추론 시에도 동일한 범위를 사용해야 합니다.


9. Self-Attention in U-Net - 전역적 일관성을 위한 장거리 의존성

시작하며

여러분이 사람 얼굴을 생성할 때, 왼쪽 눈과 오른쪽 눈의 크기와 위치가 대칭을 이루어야 자연스럽습니다. 하지만 CNN은 local receptive field를 가지기 때문에, 멀리 떨어진 픽셀 간의 관계를 파악하기 어렵습니다.

이런 문제는 Conv 레이어의 제한된 수용 영역 때문에 발생합니다. 3x3 Conv를 여러 번 쌓아도 수십 픽셀 이상 떨어진 영역의 관계를 포착하려면 매우 깊은 네트워크가 필요합니다.

예를 들어, 64x64 이미지에서 좌상단과 우하단의 일관성을 유지하기 어렵습니다. 바로 이럴 때 필요한 것이 Self-Attention입니다.

모든 위치의 픽셀이 서로 직접 소통할 수 있게 하여, 전역적 구조와 대칭성, 일관성을 유지하면서 이미지를 생성할 수 있습니다.

개요

간단히 말해서, Self-Attention은 이미지의 모든 공간 위치 간에 Query-Key-Value 메커니즘으로 유사도를 계산하고, 유사한 위치의 정보를 가중합하여 장거리 의존성을 모델링하는 레이어입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 전역적 일관성이 없으면 이미지의 부분부분은 그럴듯해 보여도 전체적으로 어색합니다.

예를 들어, 건물 이미지에서 원근법이 맞지 않거나, 좌우 대칭이어야 할 객체가 비대칭이거나, 반복되어야 할 패턴이 중간에 끊기는 문제가 발생합니다. Self-Attention이 이런 전역적 제약을 학습합니다.

기존의 순수 CNN 아키텍처에서는 장거리 의존성을 포착하려면 매우 깊게 쌓아야 했다면, 이제는 단 하나의 Self-Attention 레이어로 이미지 전체의 관계를 모델링할 수 있습니다. 이 개념의 핵심 특징은 첫째, O(N^2) 복잡도로 모든 위치 쌍을 고려, 둘째, Multi-Head로 다양한 관계 패턴 학습, 셋째, 해상도가 낮은 레벨에만 적용하여 계산 효율성 유지입니다.

이러한 특징들이 Stable Diffusion이 전역적으로 일관된 고품질 이미지를 생성하게 만듭니다.

코드 예제

import torch
import torch.nn as nn

class SelfAttention(nn.Module):
    def __init__(self, channels=320, num_heads=8):
        super().__init__()
        self.num_heads = num_heads
        self.head_dim = channels // num_heads

        # Query, Key, Value를 모두 동일한 입력에서 생성
        self.to_qkv = nn.Linear(channels, channels * 3, bias=False)
        self.to_out = nn.Linear(channels, channels)

    def forward(self, x):
        # x: (batch, channels, height, width) - 예: (1, 320, 64, 64)
        b, c, h, w = x.shape

        # (batch, h*w, channels)로 변환
        x = x.view(b, c, h * w).transpose(1, 2)

        # QKV 생성
        qkv = self.to_qkv(x)  # (batch, h*w, channels*3)
        q, k, v = qkv.chunk(3, dim=-1)

        # Multi-Head로 분할: (batch, h*w, heads, head_dim)
        q = q.view(b, h * w, self.num_heads, self.head_dim).transpose(1, 2)
        k = k.view(b, h * w, self.num_heads, self.head_dim).transpose(1, 2)
        v = v.view(b, h * w, self.num_heads, self.head_dim).transpose(1, 2)

        # Self-Attention: 모든 위치 간 유사도 계산
        attn = (q @ k.transpose(-2, -1)) / (self.head_dim ** 0.5)
        attn = attn.softmax(dim=-1)  # (batch, heads, h*w, h*w)

        # Attention으로 Value 집계
        out = attn @ v  # (batch, heads, h*w, head_dim)
        out = out.transpose(1, 2).reshape(b, h * w, c)
        out = self.to_out(out)

        # 원래 shape으로 복원
        out = out.transpose(1, 2).view(b, c, h, w)
        return out + x.transpose(1, 2).view(b, c, h, w)  # Residual connection

설명

이것이 하는 일: 각 픽셀 위치가 이미지 전체의 모든 위치와 유사도를 계산하고, 유사한 위치의 특징을 가져와 결합하여 전역적 맥락을 반영한 특징을 생성합니다. 첫 번째로, 입력을 공간적 차원으로 펼칩니다.

(batch, 320, 64, 64)를 (batch, 4096, 320)으로 변환하면 각 픽셀이 하나의 토큰처럼 취급됩니다. 이는 Transformer의 시퀀스 처리와 동일한 방식입니다.

4096은 64x64 공간 위치의 개수입니다. 그 다음으로, to_qkv로 한 번에 Query, Key, Value를 생성합니다.

Cross-Attention과 달리 Self-Attention은 모두 같은 입력 x에서 만듭니다. Chunk(3)으로 분리하면 각각 (batch, 4096, 320)이 됩니다.

Multi-Head로 나누는 이유는 Cross-Attention과 동일합니다. 서로 다른 관계 패턴을 병렬로 학습하기 위함입니다.

Attention matrix 계산 시 (q @ k.transpose())는 (batch, heads, 4096, 4096) shape이 됩니다. 이는 매우 큽니다!

예를 들어, batch=1, heads=8이라도 8 * 4096 * 4096 * 4bytes = 512MB나 됩니다. 그래서 U-Net에서는 Self-Attention을 해상도가 낮은 레벨(16x16, 32x32)에만 적용합니다.

64x64에 적용하면 메모리가 폭발합니다. Attention의 의미를 보면, attn[0,0,100,500]은 "100번째 픽셀이 500번째 픽셀과 얼마나 관련 있는지"를 나타냅니다.

예를 들어, 얼굴 이미지에서 왼쪽 눈(위치 100)과 오른쪽 눈(위치 500)은 높은 attention을 가질 것입니다. 왜냐하면 대칭적으로 유사한 특징을 공유하기 때문입니다.

Residual connection (out + x)이 중요합니다. Self-Attention은 전역 정보를 추가하는 역할이고, 기본적인 지역 특징은 residual path로 보존됩니다.

이를 통해 지역적 디테일과 전역적 일관성을 모두 유지할 수 있습니다. 여러분이 이 코드를 사용하면 생성된 이미지가 전체적으로 조화롭고 일관성 있게 만들어집니다.

실무에서는 대칭적 객체, 반복 패턴, 원근법 같은 전역적 제약이 중요한 도메인에서 필수적입니다.

실전 팁

💡 Self-Attention은 O(N^2) 복잡도라 매우 비쌉니다. 16x16(256 토큰) 레벨에만 적용하고, 64x64에는 적용하지 마세요. 메모리와 시간이 16배 증가합니다.

💡 Flash Attention이나 Memory-Efficient Attention을 구현하면 메모리를 크게 절약할 수 있습니다. PyTorch 2.0의 scaled_dot_product_attention을 사용하세요.

💡 Attention map을 시각화하면 모델이 어떤 전역적 관계를 학습했는지 알 수 있습니다. 디버깅에 매우 유용합니다.

💡 LoRA fine-tuning 시 Self-Attention 레이어를 포함하면 스타일 학습이 더 잘 됩니다. 전역적 패턴을 포착하기 때문입니다.

💡 Batch size가 클 때는 checkpoint를 사용해 activation을 재계산하여 메모리를 절약할 수 있습니다. 속도는 느려지지만 OOM을 방지합니다.


10. Conditioning Mechanisms - 다양한 조건을 주입하는 방법들

시작하며

여러분이 텍스트뿐만 아니라 스케치, 포즈, 깊이 맵 등 다양한 조건으로 이미지를 제어하고 싶다면 어떻게 해야 할까요? Stable Diffusion의 기본 구조는 텍스트 조건만 지원하는데, ControlNet이나 T2I-Adapter 같은 확장이 가능한 이유가 무엇일까요?

이런 문제는 서로 다른 모달리티의 조건을 U-Net에 효과적으로 주입하는 통일된 메커니즘이 필요하기 때문입니다. 텍스트는 Cross-Attention으로, 이미지 조건은 Concat이나 AdaIN으로, 시간 정보는 AdaGN으로 각각 다르게 처리됩니다.

바로 이럴 때 필요한 것이 다양한 Conditioning Mechanisms입니다. 조건의 특성에 맞는 주입 방법을 선택하여, 텍스트, 이미지, 스타일, 시간 등 모든 종류의 제어를 효과적으로 반영할 수 있습니다.

개요

간단히 말해서, Conditioning Mechanisms는 조건 정보를 U-Net에 주입하는 다양한 기법들의 집합으로, Cross-Attention(텍스트), Concatenation(공간 조건), AdaIN(스타일), AdaGN(타임스텝) 등이 포함됩니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 단일 조건만으로는 복잡한 제어가 어렵습니다.

예를 들어, "사진 같은 인물 초상화"를 생성하되 특정 포즈를 취하고 특정 색상 팔레트를 사용하려면, 텍스트 + 포즈 스케치 + 색상 참조를 동시에 조건으로 줘야 합니다. 각 조건에 맞는 주입 방법이 필요합니다.

기존의 단일 조건 모델에서는 하나의 conditioning 방식만 사용했다면, 이제는 여러 방식을 조합하여 multi-modal, multi-level 제어가 가능합니다. 이 개념의 핵심 특징은 첫째, 조건의 모달리티와 특성에 맞는 방법 선택, 둘째, 여러 조건의 동시 사용 가능, 셋째, 플러그인 방식의 확장성입니다.

이러한 특징들이 Stable Diffusion 생태계의 다양한 확장(ControlNet, IP-Adapter 등)을 가능하게 만듭니다.

코드 예제

import torch
import torch.nn as nn

class ConditioningDemo(nn.Module):
    def __init__(self, channels=320):
        super().__init__()

        # 1. Cross-Attention for text
        self.cross_attn = nn.MultiheadAttention(channels, num_heads=8, batch_first=True)

        # 2. AdaGN for timestep
        self.norm = nn.GroupNorm(32, channels)
        self.time_mlp = nn.Linear(1280, channels * 2)

        # 3. Concatenation for spatial conditions (예: ControlNet)
        self.conv_merge = nn.Conv2d(channels * 2, channels, 3, padding=1)

    def forward(self, x, text_emb, time_emb, control_hint=None):
        # x: (batch, channels, h, w)
        b, c, h, w = x.shape

        # 1. Cross-Attention으로 텍스트 조건 주입
        x_flat = x.view(b, c, h*w).transpose(1, 2)  # (b, h*w, c)
        text_cond, _ = self.cross_attn(x_flat, text_emb, text_emb)
        text_cond = text_cond.transpose(1, 2).view(b, c, h, w)
        x = x + text_cond  # Residual

        # 2. AdaGN으로 타임스텝 조건 주입
        x_norm = self.norm(x)
        scale, shift = self.time_mlp(time_emb).chunk(2, dim=1)
        scale = scale.view(b, c, 1, 1)
        shift = shift.view(b, c, 1, 1)
        x = x_norm * (1 + scale) + shift

        # 3. Concatenation으로 공간 조건 주입 (ControlNet 방식)
        if control_hint is not None:
            x = torch.cat([x, control_hint], dim=1)  # (b, c*2, h, w)
            x = self.conv_merge(x)  # (b, c, h, w)

        return x

# 사용 예시
model = ConditioningDemo()
x = torch.randn(1, 320, 32, 32)
text_emb = torch.randn(1, 77, 320)
time_emb = torch.randn(1, 1280)
control_hint = torch.randn(1, 320, 32, 32)  # 포즈 스케치 등

output = model(x, text_emb, time_emb, control_hint)

설명

이것이 하는 일: 조건의 특성에 따라 최적의 주입 메커니즘을 선택하여, 의미론적 조건(텍스트)은 attention으로, 시간 정보는 normalization으로, 공간 정보는 concat으로 각각 처리합니다. 첫 번째로, Cross-Attention은 텍스트 같은 시퀀스 조건에 적합합니다.

왜냐하면 텍스트의 각 단어가 이미지의 서로 다른 부분과 관련될 수 있기 때문입니다. Multi-head attention을 사용하여 "a red car next to a blue house"에서 "red"는 car 영역에, "blue"는 house 영역에 선택적으로 반영됩니다.

Residual connection으로 더하면 원본 특징은 보존하면서 텍스트 정보만 추가됩니다. 그 다음으로, AdaGN(Adaptive Group Normalization)은 전역적 스칼라 조건에 적합합니다.

타임스텝은 이미지 전체에 균일하게 영향을 주므로, 각 채널의 통계를 조정하는 것이 효과적입니다. Time_mlp가 1280차원 time embedding을 channels*2로 변환하고, chunk로 scale과 shift로 나눕니다.

Normalized_x * (1 + scale) + shift는 각 채널의 평균과 분산을 타임스텝에 따라 동적으로 조정합니다. Concatenation은 공간적으로 정렬된 조건(스케치, 깊이 맵, 포즈)에 적합합니다.

ControlNet에서 사용하는 방식으로, control hint의 각 픽셀이 출력 이미지의 해당 픽셀을 직접 제어합니다. Channel-wise concat 후 1x1이나 3x3 conv로 merge하면 두 정보가 결합됩니다.

예를 들어, 포즈 스케치의 (10, 20) 위치가 생성 이미지의 (10, 20) 위치에 직접 영향을 줍니다. 각 방법의 선택 기준: 조건이 시퀀스면 Cross-Attention, 전역 스칼라면 AdaIN/AdaGN, 공간 정렬되어 있으면 Concat, 저차원 벡터면 FiLM을 사용합니다.

여러 조건을 동시에 사용할 때는 각각의 최적 방법을 조합합니다. 여러분이 이 코드를 사용하면 단일 텍스트를 넘어 다양한 조건으로 이미지를 정밀하게 제어할 수 있습니다.

실무에서는 ControlNet, IP-Adapter, T2I-Adapter 같은 확장이 모두 이런 메커니즘을 활용합니다.

실전 팁

💡 여러 조건을 사용할 때는 각각의 가중치를 조절할 수 있게 하세요. 예를 들어, 텍스트 강도 0.7, 포즈 강도 0.3처럼 균형을 맞출 수 있습니다.

💡 ControlNet을 사용할 때는 zero convolution으로 초기화하세요. 학습 초반에 control 신호가 너무 강하면 기존 가중치를 망칠 수 있습니다.

💡 IP-Adapter처럼 이미지 조건을 줄 때는 CLIP 이미지 인코더로 임베딩한 후 Cross-Attention을 사용하면 효과적입니다.

💡 조건이 많을수록 메모리 사용량이 증가합니다. 필요한 조건만 선택적으로 사용하고, 불필요한 것은 제거하세요.

💡 Custom conditioning을 추가할 때는 기존 모델을 freeze하고 새로운 레이어만 학습하면 빠르고 안정적입니다.


#AI#StableDiffusion#DiffusionModel#VAE#UNet

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.