Stable Diffusion特征提取揭秘:AI绘图高手如何精准捕捉图像关键信
- Stable Diffusion特征提取揭秘:AI绘图高手如何精准捕捉图像关键信息
Stable Diffusion特征提取揭秘:AI绘图高手如何精准捕捉图像关键信息
“为什么别人的AI图能把猫画成少女,而我的猫却像被车碾过三次?”
别急,问题八成出在“特征提取”这一步——它就像相亲时的第一印象,一旦跑偏,后面再努力也救不回来。
从像素到语义:图像信息是怎么被“揉碎”的
先别被“特征”两个字吓到。你可以把它想象成“关键词”,只不过关键词是给人类看的,特征是给模型看的。
Stable Diffusion(下文简称SD)干活时,会把一张 512×512 的图先压成 64×64 的“潜空间小图”,再压成 4×77×768 的“文字向量”,最后压成……啊不,是扩成一张新图。
这一压一扩之间,全靠特征提取器把“像素级废话”过滤掉,留下“语义级金句”。
举个例子:
原图里有一只戴墨镜的柯基,在海边冲浪。
像素级废话:沙滩上有 183 万个 RGB 值,其中 42 万个接近 #D2B48C。
语义级金句:狗、墨镜、冲浪板、海水、阳光、欢乐。
特征提取器就是那位把 183 万句废话提炼成 6 个关键词的编辑——如果它把“墨镜”误写成“黑眼圈”,你后面生成的图就等着收获一只熬夜狗吧。
特征提取在 SD 里的“三重门”
SD 的 pipeline 里,真正动刀子的就三位:VAE、CLIP、U-Net。
他们分工明确,像一家三口吃火锅:
- VAE 负责把菜切成超薄薄片(压缩图像)
- CLIP 负责把菜单写成 Emoji(压缩文本)
- U-Net 负责在锅里来回涮(去噪还原)
下面我们把三位大哥依次拉出来,看看他们到底怎么“抓重点”。
VAE:把 512×512 压成 64×64,还不让美女变大妈
VAE(Variational Autoencoder)是 SD 的“图像压缩机”。
输入:RGB 图像,shape=(b,3,512,512)
输出:潜空间向量,shape=(b,4,64,64)
压缩率 8×8×3/4=48 倍,堪称深度学习界“7-Zip”。
但它可不是无脑像素块平均,而是学到一组“视觉单词”。
代码走一个,用 diffusers 库把 VAE 单拎出来玩:
from diffusers import AutoencoderKL
import torch
from PIL import Image
from torchvision import transforms
# 1. 加载官方 SD1.5 的 VAE
vae = AutoencoderKL.from_pretrained("runwayml/stable-diffusion-v1-5", subfolder="vae").cuda()
# 2. 读入一张高清美女图
tform = transforms.Compose([
transforms.Resize(512),
transforms.CenterCrop(512),
transforms.ToTensor() # 范围 [0,1]
])
img = Image.open("beauty.jpg").convert("RGB")
x = tform(img).unsqueeze(0).cuda() # (1,3,512,512)
# 3. 编码到潜空间
with torch.no_grad():
z = vae.encode(x).latent_dist.sample() * 0.18215 # 论文里的缩放系数
print("潜空间 shape:", z.shape) # torch.Size([1, 4, 64, 64])
# 4. 再解码回来,看会不会变大妈
x_rec = vae.decode(z / 0.18215).sample
x_pil = transforms.ToPILImage()(x_rec[0].cpu())
x_pil.save("beauty_rec.jpg")
运行结果:如果美女鼻子变塌,多半是 VAE 的“字典”里没收录这么精致的鼻梁——换模型或微调 VAE 可解,下文会聊。
CLIP:把“少女 猫 墨镜”变成 77×768 的矩阵
CLIP 就像一位冷酷的图书管理员,你把“a cat wearing sunglasses”递给它,它啪地甩给你一张 77×768 的矩阵,并附赠一句:“拿好,别弄丢。”
SD 训练时,用了 OpenAI 的 ViT-L/14 版本,输出维度 768。
下面我们把文本编码器单独请出来,看看它到底怎么“断句”:
from transformers import CLIPTextModel, CLIPTokenizer
import torch
text_encoder = CLIPTextModel.from_pretrained("runwayml/stable-diffusion-v1-5", subfolder="text_encoder").cuda()
tokenizer = CLIPTokenizer.from_pretrained("runwayml/stable-diffusion-v1-5", subfolder="tokenizer")
prompt = "a cat wearing sunglasses, digital art, trending on artstation"
tokens = tokenizer(prompt, padding="max_length", max_length=77, return_tensors="pt")
print("token ids:", tokens.input_ids) # 观察 BOS、EOS、PAD 的位置
with torch.no_grad():
embeds = text_encoder(tokens.input_ids.cuda())[0] # (1,77,768)
print("文本特征 shape:", embeds.shape)
# 可视化一下每个 token 的 L2 范数,看看谁最“抢眼”
import matplotlib.pyplot as plt
plt.bar(range(77), embeds[0].norm(dim=1).cpu())
plt.title("Token 语义强度")
plt.savefig("token_strength.png")
你会发现“sunglasses”对应的 bar 特别高,说明 CLIP 把它当成关键词;而“digital”被挤在角落,强度低——这就是后面生成时“墨镜”比“数字风”更抢眼的原因。
U-Net:在 64×64 的“小人国”里搭乐高
U-Net 是 SD 的“主厨”,负责把一张纯噪声图反复去噪 50 次,最终拼出你想要的画面。
它最骚的操作是“跨层跳连”+“交叉注意力”,让 64×64 的粗糙特征和高分辨率细节能互通有无。
把 U-Net 拆给你看:
from diffusers import UNet2DConditionModel
unet = UNet2DConditionModel.from_pretrained("runwayml/stable-diffusion-v1-5", subfolder="unet").cuda()
# 伪造一份噪声和文本条件
b, c, h, w = 1, 4, 64, 64
noise = torch.randn(b, c, h, w).cuda()
timestep = torch.tensor(500).long().cuda() # DDPM 的 500 步
encoder_hidden_states = embeds # 来自上面的 CLIP
# 跑一次去噪
with torch.no_grad():
noise_pred = unet(noise, timestep, encoder_hidden_states).sample
print("U-Net 预测噪声 shape:", noise_pred.shape) # 同输入 (1,4,64,64)
注意:U-Net 中间层偷偷存了 8 层 attention map,shape 为 (h//8, w//8, 77),也就是 (8,8,77)。
你可以把它抽出来,看看到底哪些像素在偷看“sunglasses”这个词:
# _hook 中间 attention map
attn_maps = []
def hook_fn(module, input, output):
# output[1] 就是 attn_probs
attn_maps.append(output[1].detach().cpu())
# 找到 cross-attention 层
for name, module in unet.named_modules():
if "attn2" in name and "transformer_blocks" in name: # text cross-attn
module.register_forward_hook(hook_fn)
# 重新跑一次
_ = unet(noise, timestep, encoder_hidden_states).sample
print("抓到 attention map 数量:", len(attn_maps)) # 一般 16 层
把最后一层的 map 拿出来,reshape 成 (8,8,77),再挑“sunglasses”那列 reshape (8,8) 用热图显示——亮的地方就是模型“盯墨镜”的区域。
如果你发现亮区落在猫尾巴上,生成图里墨镜大概率也会歪到尾巴——这就是“语义错位”的根源。
优点大盘点:SD 为啥能火出圈
- 潜空间操作 = 显存友好
64×64 的 feature map 比起直接卷 512×512,显存直接降到 1/64,消费卡也能玩。 - 文本-视觉对齐 = 指哪打哪
CLIP 把“银发、红瞳、机械臂”量化成向量,U-Net 在反向去噪时一路参考,效果堪比 GPS 导航。 - 社区轮子多到用不过来
LoRA、DreamBooth、ControlNet、IP-Adapter、T2I-Adapter……全是在特征层动手脚,即插即用。
缺点也不少:翻车现场合集
- 边缘糊成油画
根源:VAE 的 4-channel latent 对高频信号不友好,线条被当成噪声压掉。
缓解:- 换 SDXL 的 VAE(16-channel)
- 后处理用超分 ESRGAN 再走一次
- 提示词稍微改个介词,人物就从 18 岁变 81 岁
根源:CLIP 对语序、介词不敏感,全靠权重硬记。
缓解:- 用 prompt weighting 把核心词拉到 1.5,介词降到 0.7
- 训练自己的文本编码器,比如 Waifu-Diffusion 的 anime 版 CLIP
- 多人同框必崩
根源:U-Net 的 8×8 attention 分辨率有限,挤不下两张脸的高频细节。
缓解:- Latent Couple 分区域注入,让左半脸和右半脸各自占山为王
- 拉高潜空间分辨率到 96×96(需要重新训练 U-Net,土豪随意)
实战:自定义特征引导,让模型画“你的名字”同款滤镜
目标:生成一张“新海诚风格”的动漫海报,天空要粉、建筑要细、云要流动。
思路:
- 找 20 张新海诚剧照,用 VAE 编码成 latent,取平均向量 = 风格锚点。
- 把锚点插到 U-Net 的 mid-block,让每一次去噪都朝该风格靠。
- 文本提示只写“a girl standing on the rooftop”,不加“Makoto Shinkai”防止版权争议。
代码骨架:
# 1. 批量编码风格参考图
style_latents = []
for path in glob("shinkai/*.jpg"):
img = tform(Image.open(path).convert("RGB")).unsqueeze(0).cuda()
with torch.no_grad():
z = vae.encode(img).latent_dist.mode() * 0.18215
style_latents.append(z)
style_anchor = torch.stack(style_latents).mean(dim=0, keepdim=True)
# 2. 重写 U-Net 的 forward,把 mid-block 加偏移
class MyUNet(UNet2DConditionModel):
def forward(self, sample, timestep, encoder_hidden_states, **kwargs):
# 先正常跑
ret = super().forward(sample, timestep, encoder_hidden_states, **kwargs)
# 在 mid-block 的特征上加风格偏移
if hasattr(self, "style_anchor"):
ret.sample = ret.sample + 0.2 * self.style_anchor
return ret
# 3. 替换原 U-Net
my_unet = MyUNet.from_pretrained("runwayml/stable-diffusion-v1-5", subfolder="unet").cuda()
my_unet.style_anchor = style_anchor
# 4. 正常走 SD pipeline
from diffusers import StableDiffusionPipeline
pipe = StableDiffusionPipeline(vae=vae, text_encoder=text_encoder, tokenizer=tokenizer,
unet=my_unet, scheduler=scheduler, safety_checker=None)
pipe = pipe.to("cuda")
image = pipe("a girl standing on the rooftop", num_inference_steps=30, guidance_scale=7.5).images[0]
image.save("shinkai_girl.png")
效果:即使提示词里没出现“新海诚”,天空也会自带粉蓝渐变,建筑边缘自带光晕——这就是“风格特征”在潜空间插队的威力。
微调 CLIP,让模型听懂“黑丝”而不是“black silk”
中文圈常用 prompt 里,“黑丝”会被 CLIP 拆成“black”+“silk”,结果生成图里可能出现一条黑色丝绸围巾,而不是腿上的丝袜。
解决:用中文语料额外训练 2 个 epoch,把“黑丝”训练成独立 token,embedding 初始值用“black stockings”平均。
训练代码(伪代码,基于 transformers):
from transformers import CLIPTextConfig, CLIPTextModel
config = CLIPTextConfig.from_pretrained("openai/clip-vit-large-patch14")
model = CLIPTextModel(config)
# 在 tokenizer 里加新词
num_added = tokenizer.add_tokens(["黑丝"])
model.resize_token_embeddings(len(tokenizer))
# 构造中文-图像对,损失用 CLIP 对比损失
for batch in dataloader:
chinese_text, images = batch
text_feat = model(chinese_text).last_hidden_state
vis_feat = vision_encoder(images)
loss = contrastive_loss(text_feat, vis_feat)
loss.backward()
两周后,你会发现 prompt 里写“黑丝”再也不会飞出一条黑色围巾——模型终于看懂宅男的暗语。
结合 ControlNet,让特征提取听“施工图”的话
ControlNet 在 U-Net 的每个 block 旁再开了一个“旁路”,把 canny 边缘、深度图、姿态骨架等外部条件也转成 4×64×64 的特征图,然后与原特征做相加。
一句话:原本 U-Net 只看文本,现在还多了一张“施工图”,谁还敢乱盖楼?
示例:用深度图控制远景建筑不崩透视。
from controlnet_aux import MidasDetector
from diffusers import StableDiffusionControlNetPipeline
depth_detector = MidasDetector.from_pretrained("lllyasviel/Annotators")
control_image = depth_detector("city_input.jpg")
controlnet = ControlNetModel.from_pretrained("lllyasviel/sd-controlnet-depth")
pipe = StableDiffusionControlNetPipeline.from_pretrained(
"runwayml/stable-diffusion-v1-5", controlnet=controlnet
).to("cuda")
image = pipe(
prompt="futuristic city, skyscrapers, sunset",
image=control_image,
num_inference_steps=30,
controlnet_conditioning_scale=0.7 # 权重太高会呆板,太低会放飞
).images[0]
image.save("city_depth.png")
特征层面到底发生了什么?
ControlNet 的旁路把深度图编码成和 U-Net 同维度的特征,然后在每个 scale 做 element-wise add,等于告诉 U-Net:“兄弟,这儿是远景,别给我搞出 50 层细节。”
于是生成图里,远景窗户直接糊成反光,省下的算力全怼到前景人物,透视也不歪——这就是“空间特征”精准插桩的快感。
翻车急救包:特征提取常见故障速查表
| 症状 | 可能病灶 | 速效救心丸 |
|---|---|---|
| 脸部细节丢失,塑料感十足 | VAE 版本错位(SD1.5 模型用了 SDXL 的 VAE) | 检查 vae.config.sample_size 是否匹配 |
| 提示词“蓝色眼睛”被忽略 | CLIP 强度太低,token 被截断 | 把“blue eyes”提前到 prompt 最前,权重 ×1.4 |
| 多人同框脸黏在一起 | 8×8 attention 装不下 | 开 Latent Couple 分区域,或降到 1.5 倍步数+高清修复 |
| 生成同一张图每次差别巨大 | U-Net 某层激活值爆炸 | 把 scheduler 的 prediction_type 从 v-prediction 改回 epsilon |
| 开 4K 图直接 OOM | VAE 解码 512×512 tile 爆显存 | 用 Tiled VAE,tile=32,overlap=0.125 |
让特征提取为你所用的小技巧
Latent Couple:左右互搏术
把 64×64 的 latent 沿 width 切成两半,左半注入“1girl”,右半注入“1boy”,中间留 8 像素 overlap 做融合。
生成图里两人各占半边天,再也不抢脸。
Prompt Weighting:把“红围巾”拉满,把“微笑”调低
借助 compel 库:
from compel import Compel
compel = Compel(tokenizer=pipe.tokenizer, text_encoder=pipe.text_encoder)
prompt = "a girl wearing (red scarf:1.5) and (smile:0.7)"
conditioning = compel.build_conditioning_tensor(prompt)
原理:在 CLIP 输出后直接把对应 token 的向量乘系数,再喂给 U-Net,简单粗暴有效。
Tiled VAE:把 8G 显存压榨到 4G
原理:编码/解码时把 512×512 切成 64


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



