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”(棋盘格)。缓解办法有两个——

  1. 把stride换成2,kernel换成4,padding=1,俗称“内核尺寸=2×stride”法则,能让棋盘格轻一点。
  2. 后面再追一层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模块:老手偷偷加料的三个暗器

  1. 感受野
    上面模型最底层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个像素点,白嫖面积。

  2. 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两个卷积之间,几乎零额外计算,却能涨点,老六必备。

  3. 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,推理速度起飞,画质肉眼几乎不掉。

实战翻车现场:糊图、重影、色块断裂怎么急救

  1. 糊图
    八成是VAE解码器上采样太猛,高频细节没拉回。解决:

    • ConvTranspose换成Upsample(nearest) + 3×3卷积,棋盘格立刻老实。

    • 或者后处理加个小波锐化,两行代码:

      import cv2
      img = cv2.detailEnhance(img, sigma_s=10, sigma_r=0.15)
      
  2. 重影
    跳跃连接对不上,编码器送过来的是“左脸”,解码器已经搓到“右脸”。检查:

    • 是否手滑把ch_mult写反,导致通道数对不上。
    • 或者Upsample尺寸算错,feature map对不齐,直接F.interpolate指定size,别靠stride硬猜。
  3. 色块断裂
    潜空间出现“负值被ReLU砍断”的断层。把激活函数换成SiLUGELU,让负值也有口气,色块立马柔和。别小看这一换,肉眼可见的断层能直接消失。

调参如调情: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模块、深度可分离卷积这些小甜点有没有加?

卷积层就像女朋友,脾气摸对了,高清大图自然手到擒来。祝你下一组出图,毛孔都能数得清,别再当隔夜泡面了。

在这里插入图片描述

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值