Section 01latent space의 발상
DDPM은 사실적 이미지를 만들지만 두 가지 단점이 있다. 분류기 가이던스는 새 분류기를 매번 훈련해야 하고, 픽셀 공간 추론은 계산 비용이 막대하다. 8GB VRAM 가정용 컴퓨터에서 픽셀 공간 512×512 생성은 사실상 불가능하다.
2022년 Robin Rombach 등이 제안한 Latent Diffusion Model은 분류 문제와 성능 문제를 동시에 해결했다. 이 모델이 곧 Stable Diffusion으로 알려지게 된다.
latent space는 객체의 벡터 표현이다. 소개팅에 가기 전 중매인이 상대방을 [키, 몸무게, 나이, 취미, …] 벡터로 알려준다고 하자. 이 벡터가 곧 그 사람의 latent space다.
실제 사람의 진정한 속성 차원은 거의 무제한이지만, latent space는 제한된 수의 특징만으로 그를 표현한다. Stable Diffusion에서 인코더 ε(E)가 이미지를 latent 벡터로 인코딩하고, 디코더 D(D)가 다시 픽셀로 디코딩한다. 훈련도 샘플링도 전부 latent space에서 일어난다.
Section 02VAE로 인코딩·디코딩
구체적인 감각을 얻기 위해, 사전 훈련된 VAE로 이미지를 latent 벡터로 인코딩하고 다시 디코딩해 보자. 이미지의 각 픽셀은 0~255지만, Stable Diffusion 이미지 인코더는 −1.0 ~ 1.0 범위의 데이터를 처리한다. 또한 데이터 모양도 (너비, 높이, 채널)에서 (채널, 너비, 높이)로 바꾸고 배치 차원을 추가해야 한다.
import numpy as np # 픽셀 0~255 -> 0~1, 그 다음 -1~1 image_array = np.array(image).astype(np.float32) / 255.0 image_array = image_array * 2.0 - 1.0 # (W,H,C) -> (C,W,H) image_array_cwh = image_array.transpose(2, 0, 1) # 배치 차원 추가 -> (1,C,W,H) image_array_cwh = np.expand_dims(image_array_cwh, axis=0)
import torch from diffusers import AutoencoderKL vae_model = AutoencoderKL.from_pretrained( "runwayml/stable-diffusion-v1-5", subfolder = "vae", torch_dtype = torch.float16 ).to("cuda") # 이미지를 CUDA로 올린 뒤 latent 벡터로 인코딩 image_t = torch.from_numpy(image_array_cwh).to("cuda", dtype=torch.float16)
Section 03CLIP 텍스트 임베딩
텍스트로 생성을 가이드하려면, 프롬프트를 먼저 토큰화한 뒤 토큰 ID를 임베딩으로 인코딩해야 한다. "a running dog" 같은 프롬프트는 CLIP 토크나이저를 거쳐 토큰 ID 리스트 — tensor([[49406, 320, 2761, 1929, 49407]]) — 가 된다. 49406과 49407은 시작·끝 토큰이다.
from transformers import CLIPTokenizer, CLIPTextModel clip_tokenizer = CLIPTokenizer.from_pretrained( "runwayml/stable-diffusion-v1-5", subfolder="tokenizer") input_tokens = clip_tokenizer( "a running dog", return_tensors="pt")["input_ids"] clip_text_encoder = CLIPTextModel.from_pretrained( "runwayml/stable-diffusion-v1-5", subfolder="text_encoder").to("cuda") prompt_embeds = clip_text_encoder(input_tokens.to("cuda"))[0] # shape = torch.Size([1, 5, 768]) — 토큰마다 768차원 벡터
torch.cat([negative_prompt_embeds, prompt_embeds])로 두 임베딩을 하나의 벡터로 연결해 UNet에 한 번에 공급한다.
Section 04time step과 UNet 초기화
Euler 스케줄러로 time step을 만든다. scheduler.set_timesteps(20)을 호출하면 1,000단계 중 20개의 핵심 단계가 샘플링된다 — 999, 946.4, 893.8, … 52.6, 0. 이 단계 샘플링이 Stable Diffusion 성능 향상의 한 축이다.
마지막 빌딩 블록은 UNet이다. UNet 아키텍처는 Ronneberger 등이 생물의학 이미지 분할을 위해 도입했다. U자형 구조는 다양한 스케일에서 특징을 학습하고, 스킵 연결은 서로 다른 단계의 feature map을 직접 결합한다 — 세밀한 디테일과 전역 컨텍스트를 동시에 유지하므로 노이즈 제거에 이상적이다.
from diffusers import EulerDiscreteScheduler as Euler from diffusers import UNet2DConditionModel # 모델 전체를 로드하지 않고 스케줄러만 가져온다 scheduler = Euler.from_pretrained( "runwayml/stable-diffusion-v1-5", subfolder="scheduler") scheduler.set_timesteps(20, device="cuda") # 조건부 2D UNet — Diffusion 모델의 핵심 unet = UNet2DConditionModel.from_pretrained( "runwayml/stable-diffusion-v1-5", subfolder = "unet", torch_dtype = torch.float16 ).to("cuda")
Section 05파이프라인 조립
이제 텍스트 인코더·VAE·UNet·스케줄러가 모두 준비됐다. 가장 단순하면서 작동하는 텍스트-이미지 파이프라인을 조립할 차례다. 추론의 시작점은 무작위로 초기화된 가우시안 latent 노이즈다.
# latent tensor 모양: 배치 1, 채널 4, 64×64 shape = torch.Size([1, 4, 64, 64]) noise_tensor = torch.randn( shape, generator=None, dtype=torch.float16 ).to("cuda")
1. CLIP이 프롬프트(+네거티브)를 텍스트 임베딩으로 인코딩한다.
2. 무작위 가우시안 latent 노이즈를 시작 이미지로 삼는다.
3. 샘플링된 각 time step마다 UNet이 텍스트 임베딩을 크로스어텐션으로 받아 노이즈를 예측하고, 스케줄러가 latent에서 그 노이즈를 덜어낸다.
4. 20단계 후, VAE 디코더가 노이즈가 제거된 latent를 픽셀 이미지로 변환한다.