8.2 潛在擴散模型與 Stable Diffusion 的誕生(2021-2022)#
一、像素空間的負擔#
在第 7.3 節,我們推導了 DDPM 的完整數學:前向過程逐步加入高斯雜訊,反向過程用一個 U-Net 預測雜訊或分數。那個推導在數學上是乾淨的,但一旦要處理真實解析度的影像,就會遇到純粹的工程殘酷。
1.1 像素空間的記憶體帳單#
以 \(512 \times 512\) 的 RGB 影像為例,一張影像的張量大小是
以 float32(4 bytes)計算,單張影像就是約 3.1 MB。這個數字乘上任何一個數量級都會爆炸:
| 項目 | 512×512 像素空間 | 64×64 潛在空間 | 比值 |
|---|---|---|---|
| 每張影像的元素數 | 786,432 | 16,384 | 48× |
| float32 記憶體(單張) | 3.1 MB | 64 KB | 48× |
| 32 張的 batch(含中間特徵) | >1 GB | ~30 MB | ~48× |
| 每一步 U-Net 的啟發量(FLOPs 量級) | \(\sim 10^{11}\) | \(\sim 2 \times 10^{9}\) | ~48× |
1.2 U-Net 的參數量與瓶頸在哪裡#
SD 1.x 使用的 U-Net 約有 860M 參數,其中絕大部分集中在注意力層與 bottleneck 的通道數上。關鍵在於:U-Net 的啟發量主要由最高解析度層的特徵圖大小決定。
以 64×64 輸入、模型通道 320 為例,最淺層特徵圖是 \(320 \times 64 \times 64\) = 1.3M 個元素;batch 32 時,單是這個張量就是 168 MB(fp32)。而每一層的卷積都要讀寫這麼大的張量。在像素空間做擴散,瓶頸不是參數量,而是記憶體頻寬與特徵圖的空間體積。
第三個瓶頸是訓練步數。DDPM 原始模型需要 1000 步以上的前向擴散來學好反向過程;即使使用 DDIM 加速到 50 步,每次推理仍要跑 50 次完整的 U-Net forward。若能在 48 倍小的空間做同樣的事,生成一張 512×512 影像的時間就從數分鐘降到數秒——這正是 LDM 的核心動機。
1.3 第一個嘗試:PixelCNN 與 VQ-VAE 的啟示#
在 LDM 出現前,有兩條相關的線索:
- PixelCNN / PixelRNN(2016–2017):在像素空間用自迴歸方式生成,品質佳但推理極慢(必須逐像素依序抽樣,一張 512×512 影像需要 26 萬步)。
- VQ-VAE(2017, van den Oord 等)與 VQGAN(2021, Taming Transformers):證明了在離散潛在空間做生成可以保持高保真重建。VQGAN 搭配一個先驗網路(transformer 或 GAN)生成語意一致的圖像,且在像素空間做一次 GAN 訓練。
LDM 的關鍵推進:VQGAN 用的是離散碼本(lookup table),量化誤差是已知的品質瓶頸(codebook collapse、量化誤差累積);而 LDM 改用連續的 VAE 潛在空間,用 KL 正則讓它接近標準常態分佈——這樣一來,第 7.3 節那整套連續型擴散理論可以直接搬過去用,不需要處理離散 token 的分類分佈。
二、Latent Diffusion Model(LDM, Rombach et al., 2022, CVPR)#
Rombach 等人發表於 CVPR 2022 的 High-Resolution Image Synthesis with Latent Diffusion Models,把三件事合在一起:VAE 壓縮 → 在潛在空間做擴散 → 用 cross-attention 注入條件。
2.1 第一階段:自編碼器的設計與訓練#
架構:Encoder \(\mathcal{E}\) 與 Decoder \(\mathcal{D}\) ,架構上分別是 U-Net 與 U-Net 的鏡像:
mermaid
flowchart LR
A[Input image x: 512x512x3] --> B[Encoder U-Net]
B --> C[Latent z: 64x64x4]
C --> D[Decoder U-Net]
D --> E[Reconstructed image]
C --> F[KL divergence term]
F --> G[Prior N(0,1) regularizer]
訓練目標(LDM 論文式 3 的重組形式):
維度設計: \(512 \times 512 \times 3\) 的影像被壓成 \(64 \times 512\) ... 更精確地說,LDM 使用的 f 取 8,因此空間下採樣 8 倍,通道數從 3 變成 4:
「48 倍壓縮」的來源:空間下採樣 8 倍( \(64 \times 64\) vs \(512 \times 512\) )使面積減少 64 倍,但通道數從 3 增加到 4,使元素總數變成
所以是 48 倍的元素數壓縮,而不是 64 倍。這正是 SD 1.x 使用 scale_factor = 0.18215 的原因——這個常數是 \(1/\sqrt{0.18215}\) 關係的縮放因子,讓潛在變數的標準差接近 1,使擴散模型的雜訊 schedule 假設成立。
KL 正則的取捨:VAE 正則 vs 純 L2 重建
這是一個必須講透的取捨,因為它同時決定了 LDM 的可用性與其品質上限:
- 只用 L2 重建( \(\beta = 0\) ,純自編碼器):重建品質最高(細節保留最好),但潛在空間的後驗不集中在 \(\mathcal{N}(0,1)\) 附近,分布是彎曲的、有多個模態的。第 7.3 節的擴散理論假設 \(q(z_1) = \mathcal{N}(0,I)\) ,若不成立,擴散模型要學的東西會複雜得多,訓練也更不穩定。
- 加上 KL 正則( \(\beta > 0\) ):潛在空間被迫靠近 \(\mathcal{N}(0,I)\) ,擴散模型可以直接用標準的高斯 schedule 訓練。但代價是重建細節被丟棄——VAE 會把高頻紋理與細微色彩「平均掉」。
- KL + 感知損失 + 對抗損失:LDM 最終用 AutoencoderKL,並在第一階段加入 LPIPS 感知損失(用預訓練的 VGG 網路比較特徵)與_patch adversarial loss。後兩者的作用是把高頻細節「逼」回來,抵銷 KL 正則的損失。
後果:SD 1.x 的 VAE 在重建細緻紋理(如文字、細毛髮)上確實會糊掉——這是 8.3 節會提到的 inpainting 與 ControlNet 場景中偶爾可見的「重建不精確」問題的根源,也是 SDXL 等後續模型換用更好 VAE 的原因之一。
2.2 第二階段:把擴散搬到潛在空間#
訓練目標。第 7.3 節的 DDPM 目標是
在 LDM 中,我們把它寫在潛在空間 \(z\) 上。把 \(x_0\) 換成 \(z_0 = \mathcal{E}(x_0)\) ,雜訊過程作用在 \(z\) 上:
模型 \(\epsilon_\theta(z_t, t, c)\) 預測加入的雜訊,損失為
計算量的下降:U-Net 現在處理的是 \(64 \times 64 \times 4\) 而非 \(512 \times 512 \times 3\) ,每一步的計算與記憶體約降到 1/48。這使得 512×512(甚至 1024×1024)的生成在消費級 GPU(8–12 GB 顯存)上成為可能。
一個容易被忽略的細節:因為擴散的方差是作用在潛在空間上,而潛在空間有著與像素不同的縮放,SD 使用了 scale_factor 把 \(z\) 乘上 0.18215,使 \(\sqrt{\bar{\alpha}_t} z_0 + \sqrt{1-\bar{\alpha}_t} \epsilon\) 中的 \(z_0\) 項與雜訊項尺度相當。
2.3 第三階段(核心):Cross-Attention 與文字條件#
這是 LDM 最重要、也最具影響力的設計:如何在生成過程中注入文字條件。
2.3.1 公式#
在第 6.1 節已經介紹過 scaled dot-product attention:
在 LDM 的 U-Net 中,每個 transformer block 有兩種注意力:
- Self-Attention: \(Q = K = V\) 來自 U-Net 當前的中間特徵 \(h \in \mathbb{R}^{N \times d}\) ( \(N\) 為 token 數)。
- Cross-Attention: \(Q\) 來自 U-Net 的中間特徵, \(K, V\) 來自外部條件(文字)。
Cross-attention 的公式為
其中:
- \(h \in \mathbb{R}^{N \times d}\) :U-Net 在某個解析度層的中間特徵, \(N = H \times W\) 個空間位置。
- \(c \in \mathbb{R}^{L \times d}\) :條件序列(文字), \(L = 77\) , \(d = 768\) (SD 1.x 的 OpenCLIP ViT-L/14 輸出維度)。
- \(W_Q, W_K, W_V \in \mathbb{R}^{d \times d}\) :可訓練的投影矩陣。
2.3.2 注意力圖如何把文字「畫」進影像#
這是整個機制最直觀也最重要的一段。展開注意力權重:
\(A\) 的第 \(n\) 列、第 \(l\) 項 \(A_{n,l}\) 表示:影像上的第 \(n\) 個空間位置,對文字的第 \(l\) 個 token 分配了多少注意力。
因此 \(A\) 是一張語意對應圖(semantic correspondence map):
- 若 token \(l\) 是「dog」,而影像的某個位置是狗,那麼 \(A_{n,l}\) 在那個位置會很高。
- 這個權重再去加權 \(c W_V\) (文字的 value 投影),把「dog 的語意向量」寫入影像該位置的特徵。
換句話說:文字不是被「摻進」影像,而是透過注意力被「分配到」影像的各個位置。這是理解 LDM 條件生成最關鍵的一句話,也是理解 8.3 節 ControlNet「用結構條件取代或補充文字條件」的前提。
2.3.3 Cross-attention 層在 U-Net 中的位置#
flowchart TD
T[Text prompt 77 tokens] --> TE[CLIP Text Encoder ViT-L/14]
TE --> TE2[Context embeddings 77 x 768]
IN[Latent z_t 64x64x4] --> C1[Down block 64x64]
C1 --> A1[Cross-Attn layer 1]
TE2 --> A1
A1 --> C2[Down block 32x32]
C2 --> A2[Cross-Attn layer 2]
TE2 --> A2
A2 --> BT[Bottleneck 8x8]
BT --> A3[Cross-Attn layer 3]
TE2 --> A3
A3 --> U1[Up block 16x16]
U1 --> A4[Cross-Attn layer 4]
TE2 --> A4
A4 --> U2[Up block 32x32]
U2 --> A5[Cross-Attn layer 5]
TE2 --> A5
U2 --> OUT[Output epsilon prediction]插入位置的意義:LDM 在 U-Net 的每一個解析度層都插入 cross-attention 層,而不是只在 bottleneck。理由是:
- 高解析度層(64×64, 32×32)負責細節與紋理,此處的注意力能把文字的細微語意(顏色、材質)對應到局部區域。
- bottleneck(8×8)負責整體構圖與物件的配置,此處的注意力決定「狗出現在畫面的哪一側」。
- decoder(Up blocks) 同樣插入,因為最終輸出的像素是由 decoder 端產生的。
實作上的細節:SD 的 U-Net 每個 transformer block 實際上是 BasicTransformerBlock,包含 self-attention → cross-attention → feed-forward,並且採用殘差 + LayerNorm(post-LN)。
2.3.4 文字編碼器的輸出如何餵給 cross-attention#
CLIP 的文字編碼器輸出一個 \(77 \times 768\) 的矩陣(每個 token 位置的隱層)。這整個矩陣(包含 padding 位置的隱層)被送入 U-Net 的每一個 cross-attention 層,作為 \(K\) 與 \(V\) 。
SD 1.x 使用的是 OpenCLIP ViT-L/14 的最後一層隱層輸出(不是 EOS token),也就是完整的 \(77 \times 768\) 序列特徵。這個選擇保留了 token 級別的資訊,讓注意力可以做更細緻的對應——這正是 2.3.2 的語意對應圖能夠工作的技術前提。
2.4 條件注入的三種方式#
把條件(文字、或其他訊號)注入生成模型,大致有三種方法:
| 方式 | 機制 | 優點 | 缺點 | 代表工作 |
|---|---|---|---|---|
| Concatenation | 把條件張量當額外通道 concat 進輸入 | 實作最簡單,網路可以直接學 | 條件維度與輸入維度綁死;改變條件長度需要重訓;文字這類高維序列難以直接當通道 | pix2pix (2017) 的條件 GAN |
| Feature concatenation | concat 在網路中間層 | 同上但語意更強 | 同樣的維度綁死 | SRGAN、ResizeGAN |
| Cross-attention | 注意力把條件序列動態寫入各位置 | 條件長度自由;可保留 token 級細節;不綁死維度;可複用同一份條件 embedding | 需要改動網路結構;訓練較慢 | LDM / SD 1.x |
| Adapter | 在原模型旁掛一個小的可訓練分支 | 凍結原模型,參數少 | 控制能力受限 | T2I-Adapter (2023) |
| Control branch | 可訓練的完整副本 + 零卷積注入 | 控制能力強,可疊加 | 參數量約等於原模型一半 | ControlNet (2023) |
為什麼 cross-attention 在 SD 中勝出?
- 保留語意層級:文字經過 CLIP 編碼後是 77 個 token 的序列,每個 token 承載不同語意。Concat 會把這個序列壓扁或破壞其結構;cross-attention 讓每個影像位置可以「自己去看」哪個 token 與自己相關。
- 不綁死維度:條件可以是 77×768,也可以是任意長度的序列(這讓 IP-Adapter、GLIGEN 得以在同一架構上擴充)。
- 多粒度對應:如 2.3.3 所述,在多個解析度層插入使得不同層處理不同粒度的對應。
- 可共享:同一份文字 embedding 可以同時餵給所有層,也同時餵給 self-attention 的殘差路徑。
2.5 設計選擇:為什麼 U-Net 仍是主流骨架#
在 2022–2023 年,ViT 與 Diffusion Transformer(如 DiT, Peebles & Xie, 2023)都已出現,但 SD 1.x 仍選 U-Net,原因有四:
- Skip 連接:編碼器與解碼器之間的跳連讓高解析度細節直接傳遞,避免在瓶頸被壓縮。
- 多尺度特徵:U-Net 天然在不同解析度上操作,適合「先決定構圖、再決定紋理」的生成過程。
- 殘差結構:深層 U-Net(SD 1.x 有三個 down block)容易訓練。
- 注意力放在 bottleneck:SD 1.x 的 transformer block 主要在 32×32、16×16、8×8 層(而非最淺層),這大幅節省計算,同時讓全域資訊在深層充分混合。
DiT(2023)證明了 transformer 架構在擴散模型上更有效率(更好的 FID),SDXL 也部分採用了此思路,但 U-Net 的生態累積(LoRA、ControlNet、inpainting 等大量外掛)使它在 2023 年之前無可取代。
三、Stable Diffusion v1(2022)#
3.1 架構細節#
| 組件 | 具體設定 |
|---|---|
| Text Encoder | OpenCLIP ViT-L/14,輸出 77×768 的 context embeddings,凍結 |
| UNet | 約 860M 參數,輸入 4 通道(z)、輸出 4 通道( \(\epsilon\) 的預測) |
| VAE | AutoencoderKL, \(512 \times 512 \times 3 \leftrightarrow 64 \times 64 \times 4\) |
| 解析度 | 512×512(原生物解) |
| 訓練資料 | LAION-5B / LAION-Aesthetics 等大型圖文語料(SD v1.5 追加更多資料並重新訓練) |
| 排程器 | PNDM(原始)/DDIM(社群常用) |
| 發布形式 | 權重以 safetensors / ckpt 釋出,非商用授權 |
架構圖:
flowchart LR
P[Prompt string] --> TE[OpenCLIP ViT-L/14 text encoder]
TE --> CTX[77 x 768 context embeddings]
N[Gaussian noise 64x64x4] --> DD[UNet epsilon prediction]
CTX --> DD
DD --> SC[Scheduler update]
SC -->|repeat 20 to 50 steps| DD
SC --> Z[Latent z0 64x64x4]
Z --> DEC[VAE Decoder]
DEC --> IMG[Image 512x512x3]3.2 v1 → v1.5 → v2 → v2.1 的演進#
| 版本 | 時間 | 文本編碼器 | 訓練解析度 | 排程器 | 說明 |
|---|---|---|---|---|---|
| SD v1.0 | 2022-08 | OpenCLIP ViT-L/14 | 512×512 | PNDM | 首發 |
| SD v1.1 | 2022-09 | 同上 | 512×512 | PNDM | 微調,較少「過度銳利」 |
| SD v1.5 | 2022-10 | 同上 | 512×512 | PNDM | 用更多資料重新訓練,泛化性與 prompt 遵循度最佳,社群事實標準 |
| SD v2.0 | 2022-11 | OpenCLIP ViT-H/14 | 768×768 | DPMSolver++ | 架構數學幾乎相同,但視覺特徵明顯不同 |
| SD v2.1 | 2023-07 | 同 v2 | 768×768 | 沿用 v2 的 noise scheduler | 主要是對 v2 權重做 finetune,修正 v2 的過曝/噪點問題 |
關於「v2 與 v1 幾乎數學相同但視覺差異很大」這個常被問到的問題:LDM 的數學框架(VAE + 擴散 + cross-attention)在兩個版本間完全一致。差異來自三個純粹的工程因素:
- 文本編碼器換了:ViT-L/14 → ViT-H/14,輸出維度從 768 變成 1024,語意空間的幾何結構不同。
- 訓練解析度不同:512 → 768,改變了 U-Net 看到的 patch 尺度分布與物件大小的先驗。
- 訓練資料與資料清洗不同。
實踐教訓:checkpoint 的「視覺風格」不是架構決定的,而是資料 + 文本編碼器 + 解析度三者共同決定的。這對後續的 LoRA 與模型合併(checkpoint merging)很重要——LoRA 的效果高度依賴基底模型的特徵幾何。
3.3 推理加速#
(一)DDIM:把 1000 步降到 50 步#
第 7.3 節已導出 DDIM 的非馬可夫過程:
其中 \(\hat{z}_0 = ( z_t - \sqrt{1-\bar{\alpha}_t} \epsilon_\theta ) / \sqrt{\bar{\alpha}_t}\) 。
關鍵改變:推理時可以跳過任意多個時間步,把 \(T = 1000\) 縮成 20–50 步,且不需重新訓練。這是 SD 生態「即時性」的技術前提。
(二)Classifier-Free Guidance(CFG)#
訓練時:以機率 \(p_{\text{uncond}} = 0.1\) 把條件換成空字串:
模型因此學會 \(\epsilon_\theta(z_t, t, \varnothing) \approx \epsilon_{\text{uncond}}\) (無條件預測)。
推理時:把有條件與無條件預測外插:
\(s\) 稱為 guidance scale,典型值 7.5。
機制解讀: \(\epsilon_{\text{cond}} - \epsilon_{\text{uncond}}\) 是「這個文字條件相對於無條件的方向」,乘上 \(s\) 再加回去,等於沿著「條件方向」走過頭(overshoot),從而放大條件的影響。
常見誤解:「CFG scale 越高越好」。實際上 \(s\) 過大會造成過飽和、對比過強、色彩失真;不同模型的甜蜜點不同(SD 1.5 常見 7–9,SDXL 常見 5–7)。
(三)LCM(Latent Consistency Model, 2023)#
LCM 的作法是把一致性蒸餾(consistency distillation, Song et al., 2023)套用到潛在空間,並用 LCM-LoRA 的形式發布——大約 135 MB 的權重就可以把原本需要 20–50 步的 SD 加速到 4–8 步。這是 2023 年社群體驗的關鍵躍升(Midjourney 與 Adobe Firefly 也陸續跟進)。
(四)SDXL(2023, Stability AI)#
SDXL 是 2023 年最重要的升級之一:
- 解析度提升到 1024×1024(原生),並在訓練中加入解析度與裁切比例的條件。
- 雙文本編碼器:CLIP ViT-L (768 維) + OpenCLIP ViT-bigG (1280 維),兩者的 context embeddings 拼接後送入 U-Net。這直接解決了 SD 1.x「文字描述器容量不足」的問題。
- size / crop conditioning:把「原始影像的解析度與裁切位置」也當成條件輸入(例如
size: 1024x1024, crop: 0, 0)。這是把構圖資訊當作條件的最早形式之一——一個看似技術性的細節,卻預告了 8.3 節 ControlNet 的核心思路:生成的可控性,本質上是把額外的結構資訊以條件形式注入。
3.4 SD 生態的三個外掛方向#
到 2023 年,Stable Diffusion 的能力擴張主要沿著三條「外掛」的路徑:
| 方向 | 代表 | 解決的問題 |
|---|---|---|
| 風格 / 主體 / 概念 | LoRA、DreamBooth、Textual Inversion | 微調特定風格、角色、物件 |
| 局部修改 | Inpainting(原版 SD inpainting 模型) | 只重繪 mask 區域 |
| 結構控制 | ControlNet | 依邊緣 / 深度 / 姿勢 / 分割圖生成 |
三者的共同哲學是:凍結原始的龐大模型,只訓練一個小的附加模組。這是參數效率遷移學習的思路,也是 ControlNet 的出發點(下一節展開)。
3.5 為何 SD 重要#
(一)門檻從實驗室降到消費性裝置
LDM 是一篇論文;SD v1.0 是一組權重加上 CreativeML OpenRAIL 授權條款。這個差別極大:
- 2022 年 8 月:CompVis 釋出權重,社群在數天內湧現數百個衍生模型與工具。
- AUTOMATIC1111 的 Stable Diffusion WebUI(2022)把 diffusers 庫、checkpoint 管理、ControlNet、LoRA、放大(upscaling)整合成一個本地 GUI。
- Midjourney(2022 年 4 月起在 Discord 上開放測試)以訂閱制快速取得使用者。
- 整個「LoRA 訓練」「ControlNet 生圖」的社群文化在 2023 年成形。
(二)但它也帶來了嚴重的社會成本
- 深偽:2023 年「Balenciaga controversy」、多起名人深偽散布事件(見 10.1 節)。
- 著作權:日本動畫家 Midjourney 集體訴訟;美國訴訟「Andersen v. Stability AI」;Getty Images 對 Stability AI 起訴。
- 「複製」vs 藝術家:LDM 與 SD 的訓練資料來自網路,包含大量未經授權的藝術作品。模型學習的是「風格的可統計表徵」,而風格本身在法律上尚未被明確保護——這使得「模型是複製還是學習」成為 10.1 節的核心議題。
- 資料隱私:SD 1.x 的訓練資料 LAION-5B 被研究揭露含有大量 NSFW 內容與已知不當影像的衍生版本。
這一點必須講清楚:SD 的民主化是雙刃的——它降低了創作門檻,同時也降低了偽造的門檻。技術的因果鏈是:模型架構的進步 → 生成品質跨過「可用」門檻 → 生成成本趨近於零 → 偽造與侵權的規模化成為可能。
四、實作#
4.1 U-Net 中的 Cross-Attention 層#
import math
import torch
import torch.nn as nn
import torch.nn.functional as F
class CrossAttention(nn.Module):
"""LDM/SD 的 cross-attention:Q 來自 U-Net 特徵,K/V 來自條件序列。"""
def __init__(self, query_dim: int, context_dim: int, heads: int = 8, dim_head: int = 64):
super().__init__()
inner_dim = heads * dim_head
self.heads = heads
self.dim_head = dim_head
self.scale = dim_head ** -0.5
self.to_q = nn.Linear(query_dim, inner_dim, bias=False)
self.to_k = nn.Linear(context_dim, inner_dim, bias=False)
self.to_v = nn.Linear(context_dim, inner_dim, bias=False)
self.to_out = nn.Sequential(nn.Linear(inner_dim, query_dim), nn.Dropout(0.0))
def forward(self, x, context=None, mask=None):
"""
x : (B, N, query_dim) U-Net 中間特徵,N = H*W
context: (B, L, context_dim) 文字條件 embeddings(L = 77)
mask : (B, L) True 代表該位置為 padding,需被遮蔽
"""
if context is None:
context = x # 退化成 self-attention
B, N, _ = x.shape
L = context.shape[1]
H = self.heads
q = self.to_q(x).view(B, N, H, self.dim_head).transpose(1, 2) # (B,H,N,dh)
k = self.to_k(context).view(B, L, H, self.dim_head).transpose(1, 2)
v = self.to_v(context).view(B, L, H, self.dim_head).transpose(1, 2)
# scaled dot-product attention
attn = (q @ k.transpose(-2, -1)) * self.scale # (B,H,N,L)
if mask is not None:
attn = attn.masked_fill(mask[:, None, None, :], float("-inf"))
attn = attn.softmax(dim=-1)
out = attn @ v # (B,H,N,dh)
out = out.transpose(1, 2).reshape(B, N, H * self.dim_head)
return self.to_out(out)
class BasicTransformerBlock(nn.Module):
"""SD 的 transformer block:self-attn -> cross-attn -> FFN,各帶殘差。"""
def __init__(self, dim: int, context_dim: int, heads: int = 8):
super().__init__()
self.norm1 = nn.LayerNorm(dim)
self.attn1 = CrossAttention(dim, dim, heads) # self-attention
self.norm2 = nn.LayerNorm(dim)
self.attn2 = CrossAttention(dim, context_dim, heads) # cross-attention
self.norm3 = nn.LayerNorm(dim)
self.ff = nn.Sequential(
nn.Linear(dim, dim * 4), nn.GELU(), nn.Linear(dim * 4, dim)
)
def forward(self, x, context=None, mask=None):
x = x + self.attn1(self.norm1(x))
x = x + self.attn2(self.norm2(x), context=context, mask=mask)
x = x + self.ff(self.norm3(x))
return x
class ResBlockWithCrossAttn(nn.Module):
"""U-Net 中同時含殘差路徑與 cross-attention 的模組(SD 1.x 的 down/up block 核心)。"""
def __init__(self, in_ch: int, out_ch: int, context_dim: int, heads: int = 8):
super().__init__()
self.norm1 = nn.GroupNorm(32, in_ch)
self.conv1 = nn.Conv2d(in_ch, out_ch, 3, padding=1)
self.norm2 = nn.GroupNorm(32, out_ch)
self.conv2 = nn.Conv2d(out_ch, out_ch, 3, padding=1)
self.attn = BasicTransformerBlock(out_ch, context_dim, heads)
self.skip = nn.Conv2d(in_ch, out_ch, 1) if in_ch != out_ch else nn.Identity()
def forward(self, x, context=None, mask=None):
h = self.conv1(F.silu(self.norm1(x)))
h = self.conv2(F.silu(self.norm2(h)))
# 空間位置攤平成 token 序列餵給 transformer
B, C, Hh, Ww = h.shape
tokens = h.flatten(2).transpose(1, 2)
tokens = self.attn(tokens, context=context, mask=mask)
h = tokens.transpose(1, 2).reshape(B, C, Hh, Ww)
return h + self.skip(x)
4.2 用 diffusers 跑一次 Stable Diffusion#
import torch
from diffusers import StableDiffusionPipeline, DDIMScheduler
pipe = StableDiffusionPipeline.from_pretrained(
"runwayml/stable-diffusion-v1-5",
torch_dtype=torch.float16,
safety_checker=None,
)
pipe.scheduler = DDIMScheduler.from_config(pipe.scheduler.config)
pipe = pipe.to("mps") # 或 "cuda"
prompt = "a photo of a corgi wearing a tiny top hat, studio lighting, highly detailed"
negative_prompt = "blurry, low quality, deformed"
generator = torch.Generator().manual_seed(20231108)
image = pipe(
prompt=prompt,
negative_prompt=negative_prompt, # 對應 classifier-free guidance 的無條件分支
num_inference_steps=30, # DDIM,20~50 步即可
guidance_scale=7.5,
height=512,
width=512,
generator=generator,
).images[0]
image.save("corgi.png")
4.3 從零建立一個迷你 LDM 的訓練迴圈#
import torch
import torch.nn as nn
import torch.nn.functional as F
def extract(a, t, x_shape):
"""從 schedule 陣列取出對應 timestep 的值並 broadcast 到 x 的形狀。"""
b = t.shape[0]
out = a.gather(-1, t)
return out.reshape(b, *((1,) * (len(x_shape) - 1)))
class MiniLDM(nn.Module):
def __init__(self, context_dim: int = 768):
super().__init__()
self.conv_in = nn.Conv2d(4, 320, 3, padding=1)
self.blocks = nn.ModuleList([
ResBlockWithCrossAttn(320, 320, context_dim) for _ in range(2)
])
self.norm_out = nn.GroupNorm(32, 320)
self.conv_out = nn.Conv2d(320, 4, 3, padding=1)
self.time_embed = nn.Sequential(
nn.Linear(320, 320), nn.SiLU(), nn.Linear(320, 320)
)
def forward(self, z_t, t, context):
emb = self.time_embed(t).unsqueeze(-1).unsqueeze(-1)
h = self.conv_in(z_t) + emb
for blk in self.blocks:
h = blk(h, context=context)
return self.conv_out(F.silu(self.norm_out(h)))
def ldm_loss(model, z0, context, betas, t=None):
"""
z0 : (B, 4, 64, 64) 由 VAE encoder 產生的乾淨潛在變數
context: (B, 77, 768) CLIP 文字條件 embeddings
"""
B = z0.size(0)
if t is None:
t = torch.randint(0, len(betas), (B,), device=z0.device)
alphas = 1.0 - betas
abar = torch.cumprod(alphas, dim=0)
eps = torch.randn_like(z0)
a = extract(abar, t, z0.shape)
z_t = a.sqrt() * z0 + (1 - a).sqrt() * eps # 前向擴散
pred = model(z_t, t, context) # 預測雜訊
return F.mse_loss(pred, eps) # 簡化版 DDPM 目標
要點回顧:
CrossAttention.forward中的mask用來處理 padding token——CLIP 的 77 個位置中有 padding,必須遮蔽,否則注意力會被無意義的 padding 特徵干擾。ResBlockWithCrossAttn展示了 SD 的典型組合:殘差卷積路徑 + transformer 路徑並行相加。ldm_loss與第 7.3 節的 DDPM 目標完全一致,只是作用在 \(z\) 上;context正是 cross-attention 的 K/V 來源。- 整體資料流:文字 → CLIP → context → cross-attn 的 K/V;影像 → U-Net 特徵 → cross-attn 的 Q。這個「Q 來自影像、K/V 來自文字」的分工,就是把語意畫進去的全部機制。
五、小結#
- LDM 的因果起點是計算可行性:像素空間的 48 倍差距,把 512×512 生成從數分鐘壓到數秒。
- VAE 的 KL 正則讓擴散理論可以直接套用:潛在空間靠近 \(\mathcal{N}(0,I)\) ,高斯 forward process 的假設成立;代價是細節損失。
- Cross-attention 是把文字「畫」進影像的機制:Q 來自影像位置,K/V 來自文字 token,注意力圖是一張語意對應圖。
- Stable Diffusion v1 之所以改變世界,是因為它把權重交出來了:不是釋出一篇論文,而是釋出一組任何人都能執行、社群能無限延伸的模型。推理加速(DDIM、CFG、LCM)則把互動延遲壓到可接受範圍。
- 下一節要解決的問題:即便有了文字條件,生成依然是隨機的——同一個 prompt 每次的構圖都不同。使用者真正想要的是「保持這張草圖的構圖,只換畫風」。這正是 ControlNet(8.3)要回答的問題。
從「條件 = 語意」到「條件 = 結構」,只差一個可以被視覺化的、可微的、可插拔的分支。