Stable Diffusion画质翻倍秘籍:卷积神经网络到底动了啥手脚?
Stable Diffusion画质翻倍秘籍:卷积神经网络到底动了啥手脚?
先说句掏心窝子的——我去年第一次跑SD,出来的图跟用座机拍的一样,脸糊得能当马赛克用。气得我差点把显卡拔下来当搓衣板。后来蹲了三个月的Discord灌水群,才发现问题不在提示词,也不在显存,全tm藏在那个天天被喊“底层黑盒”的卷积神经网络里。今天咱们就把这黑盒拆开,看看它到底动了啥手脚,凭啥别人一键4K,你一键4块。
你以为SD全是Transformer?CNN正蹲在角落偷偷加戏
很多人张嘴闭嘴“Stable Diffusion是Transformer架构”,听着高大上,其实就跟说“北京烤鸭是鸭子”一样——对,但不全对。图像像素级别的那点精致感,全是CNN卷积核一寸寸搓出来的。Transformer负责把“黑丝小姐姐”四个字翻译成特征向量,可真正让黑丝有纹理、让小姐姐有毛孔的,是U-Net里那堆卷积层。它们低调得像个剧组场务,盒饭都蹲墙角吃,但没它俩灯光都打不起来。
先上个最简流程图,省得一会儿迷路:
文本 → CLIP文本编码器 → Transformer语义特征
噪声潜空间 ←→ U-Net(CNN为主)←→ VAE编码解码(还是CNN)
看到没?CNN出现频次比“家人们谁懂啊”还高。
VAE:把1024×1024大图压成64×64小土豆,还得保证毛孔都在
VAE(Variational Auto-Encoder)这哥们干的事,简单说就是把高清图压成“潜空间小土豆”,再原样吹回去。压土豆的过程全靠卷积,一层层stride=2把尺寸砍半,通道数翻倍,像极了公司裁员——人数减半,工作量翻倍。
上代码,看它是怎么把512×512×3压成64×64×4的:
class DownBlock(nn.Module):
def __init__(self, in_ch, out_ch):
super().__init__()
self.conv1 = nn.Conv2d(in_ch, out_ch, 3, padding=1)
self.conv2 = nn.Conv2d(out_ch, out_ch, 3, padding=1, stride=2) # 尺寸砍半
self.norm = nn.GroupNorm(8, out_ch)
self.act = nn.SiLU() # Swish激活,比ReLU温柔,不会一刀切掉负值
def forward(self, x):
x = self.act(self.norm(self.conv1(x)))
x = self.act(self.norm(self.conv2(x)))
return x
# 堆4个DownBlock,512→256→128→64→64,通道3→128→256→512→512
vae_encoder = nn.Sequential(
DownBlock(3, 128),
DownBlock(128, 256),
DownBlock(256, 512),
DownBlock(512, 512),
nn.Conv2d(512, 4, 3, padding=1) # 最后压成4通道潜码
)
注意最后那层卷积,kernel=3×3,padding=1,尺寸不变,只把通道砍成4。潜空间只有4通道,为啥?因为实验发现,4通道足够把人脸毛孔、衣服褶皱、背景景深都塞进去,再多就浪费显存,再少就糊。——别问,问就是ablation实验烧出来的玄学数字。
解码器反过来,用转置卷积(ConvTranspose2d)把小土豆吹回去:
class UpBlock(nn.Module):
def __init__(self, in_ch, out_ch):
super().__init__()
self.up = nn.ConvTranspose2d(in_ch, out_ch, 4, stride=2, padding=1) # ×2上采样
self.conv = nn.Conv2d(out_ch, out_ch, 3, padding=1)
self.norm = nn.GroupNorm(8, out_ch)
self.act = nn.SiLU()
def forward(self, x):
x = self.act(self.norm(self.up(x)))
x = self.act(self.norm(self.conv(x)))
return x
vae_decoder = nn.Sequential(
nn.Conv2d(4, 512, 3, padding=1),
UpBlock(512, 512),
UpBlock(512, 256),
UpBlock(256, 128),
UpBlock(128, 3), # 回到RGB三通道
nn.Tanh() # 像素值[-1,1]
)
这里有个坑:转置卷积容易被吐槽“ checkerboard artifacts”(棋盘格)。缓解办法有两个——
- 把stride换成2,kernel换成4,padding=1,俗称“内核尺寸=2×stride”法则,能让棋盘格轻一点。
- 后面再追一层3×3卷积平滑,实测有效,亲测不踩雷。
U-Net:反复“搓揉”噪声的老大哥
SD去噪的核心是U-Net,它就像洗衣机的滚筒,把噪声图翻来覆去地搓。U-Net的编码器、解码器、跳跃连接,清一色CNN。Transformer block只占中间bottleneck那一小块,像领导视察——来过,但真干活的是群众。
先看最简U-Net骨架,再慢慢加料:
class ResnetBlock(nn.Module):
def __init__(self, in_ch, out_ch, temb_ch=512, dropout=0.0):
super().__init__()
self.norm1 = nn.GroupNorm(8, in_ch)
self.conv1 = nn.Conv2d(in_ch, out_ch, 3, padding=1)
self.temb_proj = nn.Linear(temb_ch, out_ch) # 时间步嵌入
self.norm2 = nn.GroupNorm(8, out_ch)
self.dropout = nn.Dropout(dropout)
self.conv2 = nn.Conv2d(out_ch, out_ch, 3, padding=1)
if in_ch != out_ch:
self.nin_shortcut = nn.Conv2d(in_ch, out_ch, 1) # 1×1卷积升维
else:
self.nin_shortcut = nn.Identity()
def forward(self, x, temb):
h = self.act(self.norm1(x))
h = self.conv1(h)
# 把时间步信息也塞进来,让网络知道现在第几步
h += self.temb_proj(self.act(temb))[:, :, None, None]
h = self.act(self.norm2(h))
h = self.dropout(h)
h = self.conv2(h)
return h + self.nin_shortcut(x)
ResNetBlock的好处:梯度好跑,训练不掉泪。1×1卷积升维更是省计算量的老六,channel数不匹配时直接一个point-wise卷积搞定,比再堆一层3×3便宜多了。
接着搭整个U-Net:
class AttnBlock(nn.Module):
# 自注意力,Transformer同款QKV,但二维实现
def __init__(self, ch):
super().__init__()
self.q = nn.Conv2d(ch, ch, 1)
self.k = nn.Conv2d(ch, ch, 1)
self.v = nn.Conv2d(ch, ch, 1)
self.proj_out = nn.Conv2d(ch, ch, 1)
def forward(self, x):
B, C, H, W = x.shape
q = self.q(x).view(B, C, -1).permute(0, 2, 1)
k = self.k(x).view(B, C, -1)
v = self.v(x).view(B, C, -1)
attn = F.softmax(torch.bmm(q, k) / (C**0.5), dim=2)
out = torch.bmm(v, attn.permute(0, 2, 1)).view(B, C, H, W)
return x + self.proj_out(out)
把ResNetBlock + AttnBlock串起来:
class MiddleBlock(nn.Module):
def __init__(self, ch):
super().__init__()
self.block = nn.Sequential(
ResnetBlock(ch, ch),
AttnBlock(ch),
ResnetBlock(ch, ch)
)
def forward(self, x, temb):
x = self.block[0](x, temb)
x = self.block[1](x)
x = self.block[2](x, temb)
return x
编码器、解码器、跳跃连接:
class UNet(nn.Module):
def __init__(self, ch=128, ch_mult=[1,2,4,4], num_res=2):
super().__init__()
self.conv_in = nn.Conv2d(4, ch, 3, padding=1)
# 下采样
self.downs = nn.ModuleList()
in_ch = ch
for lvl, mult in enumerate(ch_mult):
out_ch = ch * mult
for _ in range(num_res):
self.downs.append(ResnetBlock(in_ch, out_ch))
in_ch = out_ch
if lvl != len(ch_mult)-1: # 最底层不 stride
self.downs.append(nn.Conv2d(in_ch, in_ch, 3, stride=2, padding=1))
# 中间
self.mid = MiddleBlock(in_ch)
# 上采样
self.ups = nn.ModuleList()
for lvl, mult in reversed(list(enumerate(ch_mult))):
out_ch = ch * mult
for _ in range(num_res+1): # 多一个ResBlock融合skip
self.ups.append(ResnetBlock(in_ch + out_ch, out_ch))
in_ch = out_ch
if lvl != 0:
self.ups.append(nn.ConvTranspose2d(in_ch, in_ch, 4, stride=2, padding=1))
self.conv_out = nn.Conv2d(ch, 4, 3, padding=1)
def forward(self, x, timesteps):
# 时间步embedding,经典sin-cos + 2层MLP
t = timestep_embedding(timesteps, self.ch).to(x.dtype)
x = self.conv_in(x)
skips = [x]
for layer in self.downs:
if isinstance(layer, ResnetBlock):
x = layer(x, t)
else: # 下采样卷积
x = layer(x)
skips.append(x)
x = self.mid(x, t)
for layer in self.ups:
if isinstance(layer, ResnetBlock):
x = torch.cat([x, skips.pop()], dim=1)
x = layer(x, t)
else: # 上采样转置卷积
x = layer(x)
return self.conv_out(x)
代码长,但核心思路一句话:下采样时stride卷积缩小尺寸,上采样时转置卷积放大,中间跳跃连接像快递小哥,把高频细节(边缘、纹理)从编码器直送解码器,省得反复压缩把毛孔压没了。
感受野、空洞卷积、SE模块:老手偷偷加料的三个暗器
-
感受野
上面模型最底层feature map只有8×8,想让它“看见”整张图的脸,得靠堆叠卷积层扩大感受野。可层数一多,梯度说罢工就罢工。于是有人把ResNetBlock里的3×3卷积换成dilated conv,俗称空洞卷积,一步到位扩大视野不增加参数量:class DilatedResBlock(nn.Module): def __init__(self, ch, dilation=2): super().__init__() self.conv1 = nn.Conv2d(ch, ch, 3, padding=dilation, dilation=dilation) self.conv2 = nn.Conv2d(ch, ch, 3, padding=1) self.norm = nn.GroupNorm(8, ch)dilation=2,感受野5×5,却只算9个像素点,白嫖面积。
-
SE模块(Squeeze-and-Excitation)
给每个通道配个“音量旋钮”,让网络自己决定哪些通道是主角,哪些是背景音:class SEBlock(nn.Module): def __init__(self, ch, r=16): super().__init__() self.squeeze = nn.AdaptiveAvgPool2d(1) self.excitate = nn.Sequential( nn.Conv2d(ch, ch//r, 1), nn.SiLU(), nn.Conv2d(ch//r, ch, 1), nn.Sigmoid() ) def forward(self, x): return x * self.excitate(self.squeeze(x))插在ResNetBlock两个卷积之间,几乎零额外计算,却能涨点,老六必备。
-
Depthwise Separable Conv
把标准卷积拆成depthwise(每个通道单独卷)+ pointwise(1×1混通道),计算量直接砍70%,手机端跑SD全靠它:class DSConv(nn.Module): def __init__(self, ch): super().__init__() self.depthwise = nn.Conv2d(ch, ch, 3, padding=1, groups=ch) self.pointwise = nn.Conv2d(ch, ch, 1) def forward(self, x): return self.pointwise(self.depthwise(x))替换掉ResNetBlock里的3×3,推理速度起飞,画质肉眼几乎不掉。
实战翻车现场:糊图、重影、色块断裂怎么急救
-
糊图
八成是VAE解码器上采样太猛,高频细节没拉回。解决:-
把
ConvTranspose换成Upsample(nearest) + 3×3卷积,棋盘格立刻老实。 -
或者后处理加个小波锐化,两行代码:
import cv2 img = cv2.detailEnhance(img, sigma_s=10, sigma_r=0.15)
-
-
重影
跳跃连接对不上,编码器送过来的是“左脸”,解码器已经搓到“右脸”。检查:- 是否手滑把
ch_mult写反,导致通道数对不上。 - 或者
Upsample尺寸算错,feature map对不齐,直接F.interpolate指定size,别靠stride硬猜。
- 是否手滑把
-
色块断裂
潜空间出现“负值被ReLU砍断”的断层。把激活函数换成SiLU或GELU,让负值也有口气,色块立马柔和。别小看这一换,肉眼可见的断层能直接消失。
调参如调情:CFG、kernel、通道数的三角恋
-
CFG(Classifier-Free Guidance)值飙到15,脸崩成毕加索?
本质是高 guidance 把噪声放大,卷积层扛不住高频扰动。温柔点,先降到7~9。还不行?把U-Net初始通道数ch从320砍到256,梯度稳了,脸也回来了。 -
kernel_size别乱改
有人觉得5×5比3×3大气,结果推理慢成PPT。3×3是老祖宗验证过的黄金尺寸,实在想加大,用两个3×3串联,感受野5×5,参数还更少,MobileNet早玩烂了。 -
GroupNum玄学
GroupNorm分组数=32最通用,但显存吃紧时改成8,速度提升10%,画质几乎不掉。别用BatchNorm,SD训练batch size经常=1,BatchNorm直接社死。
省流小结:下次翻车,先骂卷积,再骂模型
Stable Diffusion的“高清大片”滤镜,真不是靠提示词里的“8K、ultra detail”喊出来的,而是CNN卷积核一寸寸搓出来的。VAE压土豆、U-Net搓噪声、跳跃连接送快递,每一步都有明确的技术钩子可抓。下次出图糊,别急着换模型,先检查:
- VAE解码器有没有棋盘格?
- U-Net通道数是否太膨胀导致梯度炸?
- 空洞卷积、SE模块、深度可分离卷积这些小甜点有没有加?
卷积层就像女朋友,脾气摸对了,高清大图自然手到擒来。祝你下一组出图,毛孔都能数得清,别再当隔夜泡面了。

1059

被折叠的 条评论
为什么被折叠?



