#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 理解成一个“逐步擦噪声”的模型。

它不是从空白画布直接画图,而是:

  1. 先从一团随机噪声开始;
  2. 每一步预测“这团噪声里哪些部分应该被去掉”;
  3. 去噪很多步后,噪声逐渐变成一张图;
  4. 文字 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 里已经训练好的强大结构。

论文里的做法可以简化为:

  1. 拿已有 Stable Diffusion 的 UNet;
  2. 把其中一部分网络块复制一份;
  3. 原来的那份冻结,叫 locked branch
  4. 复制出来的那份可以训练,叫 trainable branch
  5. trainable branch 专门学习如何理解边缘图、深度图、姿态图等条件;
  6. 两条分支之间用一种特殊的 zero convolution 连接。

直观图示:

                prompt / timestep / noisy latent
                         │
                         ▼
             ┌───────────────────────┐
             │ 原 Stable Diffusion UNet │  冻结,不训练
             │ locked branch          │  保留原模型能力
             └───────────────────────┘
                         ▲
                         │ 注入额外结构信号
                         │
             ┌───────────────────────┐
control image│ ControlNet trainable   │  可训练,学习控制条件
────────────▶│ branch                 │
             └───────────────────────┘

更细一点,ControlNet 会在 UNet 的多个层级注入信号,而不是只在输入或输出处注入。

为什么要多层注入?

因为图像生成有不同层级的信息:

  • 浅层更接近边缘、纹理、局部细节;
  • 中层更接近部件、局部结构;
  • 深层更接近整体布局和语义。

如果只在最开始塞一张控制图,信号可能在网络深处被冲淡;如果只在最后塞,模型已经基本决定了构图,太晚了。ControlNet 在多个层级给 UNet “递纸条”,所以控制更稳定。


#五、为什么要冻结原模型

ControlNet 论文强调:原来的大模型是 production-ready 的,已经在海量图文数据上学到了非常强的生成能力。

如果我们直接拿它去训练边缘控制、姿态控制,可能出现两个问题:

  1. 灾难性遗忘:模型为了适应小规模控制数据,破坏原来的文生图能力;
  2. 数据不够:控制数据常常比原始图文训练数据小很多,直接微调整个模型容易过拟合。

所以 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 有用的残差信号。


#八、推理时用户真正怎么用

实际使用时,用户通常做三步:

  1. 准备一张输入图;
  2. 从输入图提取控制图;
  3. 把 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 不就行了吗?

理论上可以,但会有几个问题:

  1. 原来的 Stable Diffusion UNet 没见过这种输入格式;
  2. 直接改 UNet 输入层会破坏预训练结构;
  3. 条件信号只在输入处进入,深层可能用不好;
  4. 小数据训练时容易把原模型能力带偏。

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 文档:StableDiffusionControlNetPipelineControlNetModel.