#ControlNet 小白详解:为什么一张边缘图就能控制 Stable Diffusion 画什么
#先给结论
ControlNet 解决的是文生图里一个很朴素但很关键的问题:只靠文字,很难精确控制画面的空间结构。
比如你对 Stable Diffusion 说:
画一个女孩站在桥上,左手拿伞,右边有一棵树。
模型大概率能画出“女孩”“桥”“伞”“树”,但你很难保证:
- 人一定站在你想要的位置;
- 姿势一定符合你的草图;
- 建筑线条不跑偏;
- 物体轮廓、深度、人体骨架和原图一致;
- 同一个构图能稳定换风格。
ControlNet 的核心想法是:
不要只用文字控制图像,而是再给模型一张“控制图”,例如边缘图、深度图、人体姿态图、语义分割图。让模型在生成图像时同时听文字和看结构。
一句话类比:
Stable Diffusion 像一个会画画但不太听构图要求的画师;ControlNet 相当于给这个画师递了一张线稿 / 骨架 / 透视图,告诉它“内容按文字来,但布局照这张图来”。
ControlNet 论文是 2023 年的 Adding Conditional Control to Text-to-Image Diffusion Models,作者是 Lvmin Zhang、Anyi Rao、Maneesh Agrawala。它的关键贡献不是又训练了一个全新的文生图模型,而是提出了一种可以外挂到已有 Stable Diffusion 上的结构,让原模型在尽量不被破坏的前提下学会多种空间控制。
#一、先理解背景:Stable Diffusion 到底在做什么
小白可以先把 Stable Diffusion 理解成一个“逐步擦噪声”的模型。
它不是从空白画布直接画图,而是:
- 先从一团随机噪声开始;
- 每一步预测“这团噪声里哪些部分应该被去掉”;
- 去噪很多步后,噪声逐渐变成一张图;
- 文字 prompt 会在每一步告诉模型:“你应该往什么语义方向画”。
非常粗略的伪代码如下:
# Stable Diffusion 推理的极简伪代码
text = "a cute dog in a room"
text_embedding = text_encoder(text)
# latent 可以理解为压缩空间里的图像噪声
latent = random_noise()
for t in reversed(diffusion_timesteps):
# UNet 根据当前噪声、时间步、文字,预测该去掉的噪声
predicted_noise = unet(
latent=latent,
timestep=t,
text=text_embedding,
)
# scheduler 根据预测结果,把 latent 往更干净的图像推进一步
latent = scheduler.step(latent, predicted_noise, t)
image = vae.decode(latent)
这里最重要的模块是 UNet。
你可以把 UNet 理解为 Stable Diffusion 的“主画师”:
- 它一边看当前图像噪声;
- 一边看文字 embedding;
- 然后判断下一步该怎么去噪。
问题在于:文字 embedding 更擅长表达“画什么”,不擅长表达“精确画在哪里、轮廓怎么走、姿态怎么摆”。
#二、为什么只靠 prompt 不够
文字天然是离散、抽象、低带宽的。
比如“一个人张开双臂站着”,这句话对空间结构的约束很弱:
- 双臂张开多少角度?
- 人在画面左边还是中间?
- 腿是站直还是弯曲?
- 摄像机是俯视还是平视?
- 背景里的线条如何透视?
这些细节用文字描述会很长,而且模型也未必严格遵守。
所以在 ControlNet 出现前,用户常遇到一个问题:
prompt 能控制语义,但很难稳定控制结构。
而很多真实创作场景恰恰需要结构控制:
- 根据草图生成精细画面;
- 根据 Canny 边缘图保留原图轮廓;
- 根据 OpenPose 骨架控制人物姿态;
- 根据深度图保持空间关系;
- 根据语义分割图控制天空、道路、建筑、人物分别在哪里;
- 给建筑、室内、产品设计换风格但保留布局。
这就是 ControlNet 要解决的问题。
#三、最直观的 ControlNet:给扩散模型多接一根“控制线”
ControlNet 的输入不再只有文字 prompt,还多了一张条件图 condition image。
例如:
prompt: "a futuristic city, cyberpunk style"
control image: 一张城市建筑的边缘图
模型要做的是:
- 语义和风格听 prompt;
- 构图、轮廓、空间结构参考 control image。
推理流程变成:
# ControlNet 推理的极简伪代码
text = "a futuristic city, cyberpunk style"
text_embedding = text_encoder(text)
control_image = load_image("edge_map.png")
control_feature = preprocess(control_image)
latent = random_noise()
for t in reversed(diffusion_timesteps):
# ControlNet 先根据控制图,提取当前时间步需要注入的结构提示
control_residuals = controlnet(
latent=latent,
timestep=t,
text=text_embedding,
condition=control_feature,
)
# 原来的 UNet 仍然负责去噪,但会额外接收 ControlNet 给的结构信号
predicted_noise = unet(
latent=latent,
timestep=t,
text=text_embedding,
extra_residuals=control_residuals,
)
latent = scheduler.step(latent, predicted_noise, t)
image = vae.decode(latent)
这里的关键是:ControlNet 不是直接生成图像,而是给原来的 UNet 提供额外的中间特征。
可以类比成:
- UNet 是主画师;
- prompt 是文字需求;
- ControlNet 是旁边的结构指导员;
- 每画一步,结构指导员都提醒主画师:“这里该有轮廓”“这里是人体骨架”“这里应该是近处,那里是远处”。
#四、ControlNet 的核心设计:复制一份 UNet 编码器
ControlNet 最聪明的地方在于:它没有从零训练一个大模型,而是复用了 Stable Diffusion 里已经训练好的强大结构。
论文里的做法可以简化为:
- 拿已有 Stable Diffusion 的 UNet;
- 把其中一部分网络块复制一份;
- 原来的那份冻结,叫 locked branch;
- 复制出来的那份可以训练,叫 trainable branch;
- trainable branch 专门学习如何理解边缘图、深度图、姿态图等条件;
- 两条分支之间用一种特殊的 zero convolution 连接。
直观图示:
prompt / timestep / noisy latent
│
▼
┌───────────────────────┐
│ 原 Stable Diffusion UNet │ 冻结,不训练
│ locked branch │ 保留原模型能力
└───────────────────────┘
▲
│ 注入额外结构信号
│
┌───────────────────────┐
control image│ ControlNet trainable │ 可训练,学习控制条件
────────────▶│ branch │
└───────────────────────┘
更细一点,ControlNet 会在 UNet 的多个层级注入信号,而不是只在输入或输出处注入。
为什么要多层注入?
因为图像生成有不同层级的信息:
- 浅层更接近边缘、纹理、局部细节;
- 中层更接近部件、局部结构;
- 深层更接近整体布局和语义。
如果只在最开始塞一张控制图,信号可能在网络深处被冲淡;如果只在最后塞,模型已经基本决定了构图,太晚了。ControlNet 在多个层级给 UNet “递纸条”,所以控制更稳定。
#五、为什么要冻结原模型
ControlNet 论文强调:原来的大模型是 production-ready 的,已经在海量图文数据上学到了非常强的生成能力。
如果我们直接拿它去训练边缘控制、姿态控制,可能出现两个问题:
- 灾难性遗忘:模型为了适应小规模控制数据,破坏原来的文生图能力;
- 数据不够:控制数据常常比原始图文训练数据小很多,直接微调整个模型容易过拟合。
所以 ControlNet 选择:
锁住原来的 Stable Diffusion,只训练新增的控制分支。
这样有几个好处:
- 原模型的画图能力被保留;
- 新分支专心学习“怎么把控制图变成有用的结构提示”;
- 小数据也能训练;
- 可以为不同控制类型训练不同 ControlNet,例如 Canny、Depth、Pose、Segmentation。
这也是 ControlNet 能快速被社区采用的原因:它像一个外挂模块,而不是要求大家重训整套 Stable Diffusion。
#六、最关键的小机关:Zero Convolution 是什么
ControlNet 里经常被提到的概念是 zero convolution。
它其实就是一个 1×1 卷积层,只不过初始化时:
weight = 0
bias = 0
所以一开始它的输出永远是 0。
伪代码:
class ZeroConv2d(nn.Module):
def __init__(self, in_channels, out_channels):
super().__init__()
self.conv = nn.Conv2d(in_channels, out_channels, kernel_size=1)
nn.init.zeros_(self.conv.weight)
nn.init.zeros_(self.conv.bias)
def forward(self, x):
return self.conv(x)
那为什么要这样做?
因为 ControlNet 刚接到 Stable Diffusion 上时,如果新增分支一开始就输出乱七八糟的特征,可能会立刻破坏原模型生成效果。
Zero Conv 的作用是:
刚开始训练时,ControlNet 对原模型没有任何影响;训练过程中,它逐渐学会输出有用的控制信号。
可以类比成一个新来的实习助手:
- 第一天不乱指挥主画师,所以先闭嘴;
- 随着训练,它慢慢知道什么时候该提醒、提醒什么;
- 最后变成一个可靠的结构指导员。
有人会疑惑:权重全是 0,会不会梯度也是 0,学不动?
不会。因为卷积层的参数梯度取决于上游梯度和输入特征。即使当前权重是 0,只要损失函数对输出有梯度,权重仍然会更新。简单说:
# y = W * x
# 初始 W = 0,所以 y = 0
# 但 dLoss/dW = dLoss/dy * x
# 只要 x 不为 0,且 dLoss/dy 不为 0,W 就能被更新
所以 Zero Conv 既能保证初始安全,又能正常学习。
#七、ControlNet 训练时学的到底是什么
训练 ControlNet 需要成对数据:
原图 image
对应文字 caption
从原图提取出的控制图 condition
例如 Canny ControlNet:
image: 一张真实照片
caption: "a dog sitting in a room"
condition: 从 image 用 Canny 算法提取出来的边缘图
Depth ControlNet:
image: 一张真实照片
caption: "a street with buildings"
condition: 从 image 用深度估计模型提取出来的 depth map
Pose ControlNet:
image: 一张人物照片
caption: "a woman dancing"
condition: 用 OpenPose 提取出来的人体骨架图
训练目标和普通扩散模型类似:预测噪声。
伪代码如下:
# ControlNet 训练伪代码
for image, caption in dataloader:
# 1. 从原图提取控制条件,例如边缘、深度、姿态
condition = detector(image) # canny / depth / pose / segmentation
# 2. 把图像编码到 latent 空间
clean_latent = vae.encode(image)
# 3. 随机采样一个扩散时间步
t = random_timestep()
# 4. 给干净 latent 加噪声
noise = torch.randn_like(clean_latent)
noisy_latent = add_noise(clean_latent, noise, t)
# 5. 文本编码
text_embedding = text_encoder(caption)
# 6. ControlNet 根据 noisy latent、文字、条件图输出多层控制特征
control_residuals = controlnet(
noisy_latent,
t,
text_embedding,
condition,
)
# 7. 冻结的 UNet 接收这些控制特征,预测噪声
predicted_noise = frozen_unet(
noisy_latent,
t,
text_embedding,
extra_residuals=control_residuals,
)
# 8. 训练目标:预测噪声越接近真实噪声越好
loss = mse(predicted_noise, noise)
# 9. 只更新 ControlNet,不更新原 UNet
loss.backward()
optimizer.step()
这段代码想表达的关键点是:
ControlNet 不是学习“从边缘图直接画图”,而是在扩散模型每一步去噪时,学习如何把边缘图等条件转化为对 UNet 有用的残差信号。
#八、推理时用户真正怎么用
实际使用时,用户通常做三步:
- 准备一张输入图;
- 从输入图提取控制图;
- 把 prompt 和控制图一起送进 ControlNet pipeline。
以 Canny 边缘控制为例:
import cv2
import numpy as np
import torch
from PIL import Image
from diffusers import StableDiffusionControlNetPipeline, ControlNetModel, UniPCMultistepScheduler
from diffusers.utils import load_image
# 1. 读取输入图
image = load_image("input.png")
image_np = np.array(image)
# 2. 提取 Canny 边缘图
edges = cv2.Canny(image_np, 100, 200)
edges = edges[:, :, None]
edges = np.concatenate([edges, edges, edges], axis=2)
canny_image = Image.fromarray(edges)
# 3. 加载 ControlNet 和 Stable Diffusion
controlnet = ControlNetModel.from_pretrained(
"lllyasviel/sd-controlnet-canny",
torch_dtype=torch.float16,
)
pipe = StableDiffusionControlNetPipeline.from_pretrained(
"stable-diffusion-v1-5/stable-diffusion-v1-5",
controlnet=controlnet,
torch_dtype=torch.float16,
)
# 4. 换一个更快的 scheduler
pipe.scheduler = UniPCMultistepScheduler.from_config(pipe.scheduler.config)
pipe.enable_model_cpu_offload()
# 5. 生成图像
prompt = "a futuristic robot, highly detailed, cinematic lighting"
result = pipe(
prompt=prompt,
image=canny_image,
num_inference_steps=20,
controlnet_conditioning_scale=1.0,
).images[0]
result.save("output.png")
这段代码对应的含义是:
input.png决定边缘结构;prompt决定语义和风格;sd-controlnet-canny是专门理解 Canny 边缘图的 ControlNet;controlnet_conditioning_scale决定控制图影响有多强。
如果 controlnet_conditioning_scale 太低,模型可能不太听边缘图;如果太高,模型可能过度贴合边缘,画面变僵硬。
#九、不同 ControlNet 控制的是什么
ControlNet 的强大之处在于:同一套架构可以适配很多控制信号。
#9.1 Canny:控制轮廓
Canny 边缘图只保留图像里的主要边缘。
适合:
- 保留物体轮廓;
- 根据线稿生成图片;
- 让构图大体不变,但换风格。
#9.2 HED / SoftEdge:控制柔和边界
HED 边缘比 Canny 更柔和,保留更多语义边界。
适合:
- 风格迁移;
- 重绘人物或场景;
- 不想让边缘太硬时使用。
#9.3 OpenPose:控制人体姿态
OpenPose 把人物提取成骨架点和肢体连线。
适合:
- 固定人物动作;
- 生成同姿态不同角色;
- 舞蹈、动作、人物插画。
#9.4 Depth:控制远近和空间结构
Depth map 表示每个位置离相机远近。
适合:
- 保持室内、街景、建筑空间关系;
- 换风格但保留三维布局;
- 比单纯边缘更关注整体几何。
#9.5 Normal:控制表面朝向
Normal map 表示物体表面朝向,比 depth 更关注局部几何细节。
适合:
- 3D 感更强的物体;
- 保留雕塑、产品、人物脸部几何。
#9.6 Segmentation:控制语义区域
语义分割图告诉模型:哪里是天空,哪里是路,哪里是人,哪里是建筑。
适合:
- 场景布局设计;
- 自动驾驶 / 街景风格生成;
- 大区域语义位置控制。
#十、ControlNet 为什么比简单拼接条件图更好
一个朴素想法是:直接把控制图和噪声图拼在一起,输入 UNet 不就行了吗?
理论上可以,但会有几个问题:
- 原来的 Stable Diffusion UNet 没见过这种输入格式;
- 直接改 UNet 输入层会破坏预训练结构;
- 条件信号只在输入处进入,深层可能用不好;
- 小数据训练时容易把原模型能力带偏。
ControlNet 的优势是:
- 复用原 UNet 的预训练能力;
- 冻结主干,减少破坏;
- 在多个层级注入控制信号;
- Zero Conv 保证初始时不干扰原模型;
- 可以独立训练、替换、组合不同控制器。
所以 ControlNet 的本质不是“多输入一张图”这么简单,而是一个比较稳健的 结构化条件注入机制。
#十一、多个 ControlNet 可以一起用吗
可以。
例如你可以同时用:
- OpenPose 控制人物姿态;
- Depth 控制空间关系;
- Canny 控制局部轮廓。
伪代码上可以理解为:
residuals_all = 0
for controlnet, condition, scale in controls:
residuals = controlnet(latent, t, text_embedding, condition)
residuals_all += scale * residuals
predicted_noise = unet(
latent,
t,
text_embedding,
extra_residuals=residuals_all,
)
实际使用时要注意:控制越多,不一定越好。
如果多个条件互相冲突,比如姿态图要求人站着,边缘图里人坐着,模型会左右为难。ControlNet 不是魔法,它是在多个约束之间折中。
#十二、ControlNet 的历史意义
ControlNet 重要,不只是因为它效果好,而是因为它把文生图从“prompt engineering”推进到了更可靠的“结构控制”。
在 ControlNet 之前,很多用户只能不断改 prompt、调 seed、碰运气。
ControlNet 之后,创作流程更像这样:
先决定结构:草图 / 姿态 / 深度 / 分割
再决定语义:prompt
再决定风格:模型 / LoRA / style prompt
最后微调强度:conditioning scale / guidance scale
这使图像生成更接近真实设计工作流:
- 设计师可以先画草图;
- 动画师可以先定动作;
- 建筑师可以先定透视和结构;
- 普通用户可以拿一张照片换风格但保留构图。
它也启发了后续大量可控生成工作,包括 T2I-Adapter、IP-Adapter、ControlNet-XS、ControlNet for SDXL、视频 ControlNet 等。
#十三、ControlNet 的局限
ControlNet 很强,但不是万能。
#13.1 它依赖控制图质量
如果边缘图很乱,生成图也容易乱;如果姿态检测错了,人物也会跟着错。
#13.2 它不能完全替代语义理解
Canny 图只知道边缘,不知道那是猫还是狗。语义仍然主要靠 prompt 和原模型。
#13.3 控制太强会牺牲自由度
控制图约束越强,模型发挥空间越小。你可能得到结构很准但画面僵硬的结果。
#13.4 不同控制类型需要不同训练
Canny、Depth、Pose、Segmentation 往往对应不同 ControlNet 权重。一个控制器不一定能泛化到所有条件图。
#13.5 它仍然受基础模型能力限制
ControlNet 是给 Stable Diffusion 加控制,不是凭空让基础模型学会所有东西。基础模型不会画的概念,ControlNet 也很难补救。
#十四、从代码角度再压缩理解一遍
如果只保留最重要的抽象,普通 Stable Diffusion 是:
noise_pred = unet(latent, t, text)
ControlNet 是:
control = controlnet(latent, t, text, condition_image)
noise_pred = unet(latent, t, text, control)
训练时:
freeze(unet)
train(controlnet)
Zero Conv 保证一开始:
control ≈ 0
noise_pred ≈ original_unet(latent, t, text)
训练后:
control = useful_spatial_guidance
noise_pred = unet_guided_by_text_and_structure
所以 ControlNet 的一句话本质是:
在不破坏原 Stable Diffusion 的前提下,训练一个可插拔的结构控制分支,把边缘、深度、姿态、分割等空间条件转化为扩散去噪过程中的多层引导信号。
#十五、小白版最终总结
如果把图像生成比作拍电影:
- prompt 是剧本:告诉模型要拍什么;
- Stable Diffusion 是导演和摄影团队:负责把剧本拍成画面;
- ControlNet 是分镜和场面调度图:告诉大家人物站哪、线条怎么走、镜头空间怎么安排;
- Zero Conv 是安全阀:刚接入时不乱干预,训练后再慢慢发挥作用;
- 冻结原 UNet 是保护主创团队:不因为学习新调度方式而把原本的拍摄能力弄坏。
ControlNet 真正解决的问题是:
让生成模型不再只是“听文字猜图”,而是可以“按结构作画”。
这就是为什么它会成为 Stable Diffusion 生态里最重要的可控生成技术之一。
#参考资料
- Lvmin Zhang, Anyi Rao, Maneesh Agrawala. Adding Conditional Control to Text-to-Image Diffusion Models. arXiv:2302.05543, 2023.
- ControlNet 官方 GitHub:
lllyasviel/ControlNet. - Hugging Face Diffusers 文档:
StableDiffusionControlNetPipeline与ControlNetModel.