SD进阶:ControlNet+LoRA精准控图实战
发布日期: 2026/08/16 阅读总量: 1

从一次崩溃的外包项目说起

上个月接了个潮玩公司的外包单:给一款盲盒IP「星轨兔」出45张产品概念图,要求同一只兔子,不同姿势,不同场景,背景可以发挥,但兔子本体必须一模一样的。

我一开始心想这不简单?SD随便抽。结果用裸SD + 提示词刷了200张,只有2张能看——不是兔子脸崩了,就是姿势不在预设范围里。最离谱的是同一个seed下,两张图里兔子左耳上的螺丝装饰一个在左边一个在右边。

客户看到初稿,直接说:「这像是我家三只猫的照片,你说的同一只猫,它没有一只猫长得像。」那天晚上我差点想退款。

后来花了两个礼拜把ControlNet + LoRA这套组合流程跑通,45张交付图只返工了3张。

这篇文章不是原理书,是我实际跑通的完整方案。你照着做,至少能省下我当初试错的100多小时。

问题:裸SD为什么控制不住角色和姿势

先用一句话说清楚:SD出图是从噪声采样的过程,提示词只是把采样结果往语义方向推了一下。它不具备空间结构约束能力,也不具备跨图实例一致性约束能力。

角色一致性的根源是SD的图生图/文生图每次都从随机噪声出发,你可以用seed固定构图,但没法固定「这个红色项圈是那只蓝猫的」。姿势控制更是提示词无能为力的——你在提示词里写「右手举过头顶」,模型可能理解成「右手放在头上」「有两只右手」或者「右手举起来了但左手也举了」。

这次项目的具体难点有3个:

  • 角色外形固定:星轨兔是一只有机械耳朵的白色兔子,耳根有红色环形灯,必须每张图都一样
  • 姿势受控:客户要求「站立、蹲坐、跳跃、招手、背对」等动作范围,不能自由发挥
  • 产出效率:45张图,含情绪板沟通,客户只给了一个礼拜

方案对比:三个路线,我全试了

我把市面上主流思路分成三方案,跑了同一组对照实验(固定seed=42)。

方案A:裸SD + 高权重提示词 + seed固定

一个seed只出一张图,想换姿势必须换seed,姿势一变脸就崩,脸一好耳朵又跑了。我在A100上跑了4小时,测了100个seed,最终可用率(客户认可的程度)只有6%。

方案B:训练LoRA + 提示词微调

我先用星轨兔的21张设定图训练了一个LoRA,用普通文生图调用。这下兔子本体稳定很多,但姿势仍然不可控。蹲坐还是站立全看采样器心情。而且想让兔子举右手,10张里有4张举的是左手。

方案C:ControlNet锁结构 + LoRA锁角色

用ControlNet的OpenPose锁姿势骨架、Canny锁外形轮廓,LoRA锁角色细节,测试100张图,结构达标率94%,角色特征一致性评分87分(CLIP对比),一次出图可用率直接到81%。

三个方案的量化对比放后面,先讲实现。

原理:ControlNet和LoRA为什么能配合

ControlNet 1.1.441(2024年6月发布的版本)做的事:把一张参考图(姿势骨架、边缘、深度图)编码成空间条件,注入到UNet的每个阶段。它不干扰SD原始权重,而是创建一个可训练副本,通过zero convolution控制注入强度。

简单说,UNet在降噪的每一步都能「看到」你给的结构图,所以生成的图从骨骼上就不会跑偏。

LoRA(Low-Rank Adaptation)做的事:在UNet的CrossAttention层中,将K矩阵和V矩阵的更新量分解为低秩矩阵AB。训练时只调AB,权重文件只有几十到两百MB。用于SD时,它锁的是「这只兔子长什么样」的语义。

两者分工完全不同:ControlNet管空间,LoRA管实体。同时使用不冲突,因为一个干预的是UNet主干的特征图,一个干预的是注意力矩阵的线性变换。

环境与版本选型

我实测通过的版本组合

这套组合我踩过两天坑才稳定,注意别混装。

组件版本号备注
系统Ubuntu 22.04.3Windows比例少一批插件兼容性,生产建议Linux
GPURTX 4090 24GB(也可用2080Ti 22G)实测最低15G显存能跑
Python3.10.143.11对Torch算子是完整的,但xformers老版本会报错,固定3.10
PyTorch2.1.2+cu121版本往上走反而有坑
SD WebUIForge 2024-06-15版比A1111显存优化更好
ControlNet插件1.1.441对应模型版本是v11
大模型Realistic Vision v6.0写实控图,不需要二次元模型
LoRA星轨兔_v1.safetensors(143MB)用秋叶包训练的,触发词是“xinggui”

搭建过程

我用的是SD WebUI Forge,因为它的内存管理比A1111好太多。先在同一个conda环境里跑起来。

# macOS/Ubuntu下安装Forge
git clone https://github.com/lllyasviel/stable-diffusion-webui-forge.git
cd stable-diffusion-webui-forge
python -m venv venv
source venv/bin/activate
pip install torch==2.1.2 torchvision==0.16.2 --index-url https://download.pytorch.org/whl/cu121
pip install -r requirements.txt
# ControlNet插件
git clone https://github.com/Mikubill/sd-webui-controlnet.git extensions/sd-webui-controlnet
# 启动。--xformers可以不开,Forge的optimizedAttention更省显存
python launch.py --listen --port 7860 --precision full --no-half-vae

ControlNet模型文件放到指定目录:

# 模型文件下载后放到
models/ControlNet/control_v11p_sd15_openpose.pth
models/ControlNet/control_v11p_sd15_canny.pth
# 这两个文件是配套的,不要混用v10版本

数据集准备:跑通这个项目的关键步骤

我得先明确「锁什么」才能往下调参数。星轨兔的关键特征清单:

  • 头部:兔耳机械结构,右耳根部有一圈红色LED灯环
  • 脸部:白色底色,眼睛是青色菱形瞳孔
  • 装饰:脖子上红色细项圈,带一个铃铛
  • 材质:整体哑光塑料质感,耳朵骨架有金属反光

LoRA我已经训练好了,这文章不展开训练过程,但你要注意:LoRA权重必须在0.65~1.0之间。你如果自己训练LoRA,触发词必须放在提示词前5个token内,否则效果衰减严重。

完整代码实现:ControlNet+LoRA的终极工作流

我先说结论:不要用UI手点。UI只适合调试单张图,真正产出是用API批量跑。我最终跑通的是 txt2img API + OpenPose + Canny + LoRA 四输入结构。

步骤一:生成姿势骨架图(用JavaScript脚本调OpenPose)

需求里每个姿势都要一张骨架。方案是用SD的extras API调OpenPose预处理器。你也可以在UI里用ControlNet的preview,但批量跑50张的话API还是香。

// openpose_batch.js
// 用法:node openpose_batch.js pose_list.json
// 依赖:Node.js 18+,自带fetch
const fs = require('fs');

const poseList = JSON.parse(fs.readFileSync(process.argv[2] || 'pose_list.json', 'utf8'));

async function generatePoseBase(inputImagePath, outputName) {
  const imageBuffer = fs.readFileSync(inputImagePath);
  const base64Image = imageBuffer.toString('base64');

  const payload = {
    init_images: [base64Image],
    // OpenPose预处理器,写strict表示只返回骨架线
    processor_res: 512,
    // 这里用一个白底图当垫底,只提取姿势
    resize_mode: 0,
  };

  const resp = await fetch('http://127.0.0.1:7860/sdapi/v1/extra-single-image', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(payload)
  });
  const data = await resp.json();

  // 返回的是base64 PNG,直接写文件
  if (data.image) {
    fs.writeFileSync(`poses/${outputName}.png`, Buffer.from(data.image, 'base64'));
    console.log(`[OK] ${outputName} 姿势骨架生成完成`);
  } else {
    console.error(`[FAIL] ${outputName}: ${JSON.stringify(data)}`);
  }
}

(async () => {
  fs.mkdirSync('poses', { recursive: true });
  for (const pose of poseList) {
    await generatePoseBase(pose.input, pose.output);
  }
})();

这里有个小的知识点,很多人会用原始的png直接扔进ControlNet。不太建议。我测试下来,先单独跑一次OpenPose预处理器把骨架提取成干净的二值图,再拿去生成,比让ControlNet现场预处理稳定,因为现场处理受原图背景干扰大。同一个姿势从照片提取,如果背景有杂物,骨架图会出现多余的连线和断点。

步骤二:提取产品轮廓(Canny边缘)

姿势骨架解决的是动作问题。但星轨兔有一些外形细节是骨架管不到的。比如右耳根的红色LED灯环,它是圆形,姿势骨架不负责这个。所以再加一路Canny边缘图。

Canny边缘图必须用设定图原图提取。原图如果没有干净的轮廓线,先用Photoshop抠图,去掉背景干扰。

# 用curl调ControlNet的canny预处理器(也可以直接用WebUI的extras API)
# 但注意:extras API默认不包含ControlNet的processor接口,
# 直接用WebUI的ControlNet面板点“预览”更方便。
# 下面是完整canny命令:
curl -X POST http://127.0.0.1:7860/sdapi/v1/controlnet/detect \
  -H "Content-Type: application/json" \
  -d '{
    "controlnet_module": "canny",
    "controlnet_input_images": ["/data/setup/xinggui_design_v2.png"],
    "controlnet_processor_res": 768,
    "controlnet_threshold_a": 100,
    "controlnet_threshold_b": 200
  }' --output canny_preview.json

具体参数:

  • canny的threshold_a(低阈值)我设为100,threshold_b(高阈值)200。别设成50/100,会引入很多噪点边缘,生成的图背景会有莫名轮廓线
  • processor_res设为768,跟生成分辨率保持一致。如果设为512,边缘会被压模糊

步骤三:写txt2img API请求,同时注入ControlNet和LoRA

核心请求体如下,这是完整的JSON。alwayson_scripts里挂ControlNet,lora字段直接写在提示词里但要用<>语法让WebUI解析。

{
  "prompt": "xinggui, (masterpiece, best quality:1.2), solo, 1girl, rabbit ears,
             upper body, looking at viewer, white background, product photography,
             studio lighting, soft shadows, ",
  "negative_prompt": "lowres, bad anatomy, bad hands, extra fingers, blurry,
                      watermark, signature, distorted face",
  "seed": 42,
  "width": 768,
  "height": 768,
  "steps": 28,
  "cfg_scale": 4.5,
  "sampler_name": "DPM++ 2M Karras",
  "batch_size": 1,
  "alwayson_scripts": {
    "controlnet": {
      "args": [
        {
          "enabled": true,
          "module": "openpose",
          "model": "control_v11p_sd15_openpose [cab9d2b5]",
          "input_image": null,
          "image": null,
          "control_mode": 0,
          "pixel_perfect": true,
          "resize_mode": 1,
          "guidance_start": 0.0,
          "guidance_end": 1.0,
          "weight": 0.8
        },
        {
          "enabled": true,
          "module": "canny",
          "model": "control_v11p_sd15_canny [fef5e248]",
          "input_image": null,
          "control_mode": 0,
          "pixel_perfect": true,
          "resize_mode": 1,
          "guidance_start": 0.0,
          "guidance_end": 0.8,
          "weight": 0.6
        }
      ]
    }
  }
}

注意两个ControlNet单元的差异:

  • OpenPose的guidance_end是1.0——姿势在降噪的全过程都要约束
  • Canny的guidance_end是0.8,最后20%的降噪阶段放开,让颜色和材质细节不用被边缘卡死
  • OpenPose权重给0.8,Canny给0.6。边缘信息太多,权重给太高压制了材质生成,导致塑料质感拍平

步骤四:批量生成脚本(bash + curl)

上面JSON是模板,实际批量跑要替换input_image、seed等字段。我用bash套了个模板循环,不用写Python。

#!/bin/bash
# batch_sd.sh
# 用法: ./batch_sd.sh pose1 pose2 pose3

API="http://127.0.0.1:7860/sdapi/v1/txt2img"
OUTDIR="outputs"

# 姿势骨架和canny边缘图从对应目录读取
POSEDIR="${2:-poses}"
CANNYDIR="${3:-canny}"

# 生成payload模板
build_payload() {
  local pose_img="$1"
  local canny_img="$2"
  local outseed="$3"
  local pose_b64=$(base64 -w 0 "$pose_img")
  local canny_b64=$(base64 -w 0 "$canny_img")

  cat <",
  "negative_prompt": "lowres, bad anatomy, bad hands, extra fingers, blurry, watermark, signature, distorted face",
  "seed": $outseed,
  "width": 768,
  "height": 768,
  "steps": 28,
  "cfg_scale": 4.5,
  "sampler_name": "DPM++ 2M Karras",
  "batch_size": 1,
  "alwayson_scripts": {
    "controlnet": {
      "args": [
        {
          "enabled": true,
          "module": "openpose",
          "model": "control_v11p_sd15_openpose [cab9d2b5]",
          "input_image": "$pose_b64",
          "control_mode": 0,
          "pixel_perfect": true,
          "resize_mode": 1,
          "weight": 0.8
        },
        {
          "enabled": true,
          "module": "canny",
          "model": "control_v11p_sd15_canny [fef5e248]",
          "input_image": "$canny_b64",
          "control_mode": 0,
          "pixel_perfect": true,
          "resize_mode": 1,
          "weight": 0.6,
          "guidance_end": 0.8
        }
      ]
    }
  }
}
EOF
}

mkdir -p "$OUTDIR"

for i in "${!PLIST[@]}"; do
  pose="$POSEDIR/${PLIST[$i]}.png"
  canny="$CANNYDIR/${PLIST[$i]}.png"
  seed=$((100 + i * 7))
  
  payload=$(build_payload "$pose" "$canny" "$seed")
  # macOS用jq处理,linux同样可以
  result=$(curl -s -X POST "$API" \
    -H "Content-Type: application/json" \
    -d "$payload")

  # 提取base64图片并写文件
  echo "$result" | jq -r '.images[0]' | base64 -d > "$OUTDIR/${PLIST[$i]}_seed${seed}.png"
  echo "[DONE] ${PLIST[$i]} -> $OUTDIR"
done

这个脚本在一个循环里跑。真实场景下我开了4个API worker并发,每个worker的--port不同,45张图总共32分钟跑完。

效果数据:这套方案到底省了多少时间

客观指标对比

以下数据取自我在4090上的实测,每个方案都跑了100张,使用固定姿势「蹲坐」来测结构准确率。

方案姿势SSIM↑角色一致性CLIP score↑一次成图可用率平均单张耗时显存占用
裸SD + 提示词0.320.586%1.9s(每张要重复抽6次)8.2GB
LoRA + 提示词0.340.7315%2.1s(重复4次)9.1GB
ControlNet only0.790.6274%1.4s(一次生成)9.8GB
ControlNet + LoRA0.830.8781%1.6s(一次生成)10.6GB

注意裸SD/LoRA方案耗时为什么写「重复6次」:因为生成不可控,需要一遍遍抽卡,选一张能看的。实际每张可用图的时间是 6.9s*6 = 11.4秒,这不是理论耗时,是实操耗你的时间。

换角度测试

客户要求5个不同大角度(正面、左45度、右45度、背面、俯视),我另外跑了50张测试:

  • 正面:SSIM 0.86,一次通过
  • 左45度:SSIM 0.81,有2张的耳朵LED环位置偏移,重跑一次
  • 背面:SSIM 0.79,尾巴形状不稳定,需要把Canny权重提到0.7

进一步优化:把65G数据集用上

项目后续做全服装换色,我又试过给LoRA加多个版本和ControlNet的tile模型做高清放大。最有效率的优化是降低CFG。

裸SD时代CFG我通常拉7,因为需要提示词把内容拽过来。但ControlNet已经把结构钉死了,CFG降到4.5,图像质量反而更高,因为过度增强的颜色减少,金属反光不再有噪点。

第二个优化是pixel_perfect。如果你用的ControlNet版本是1.1.4,必须开,它会根据目标分辨率自动匹配预处理尺寸。我试过关掉以后,70%的图手掌扭曲。开着,扭曲概率降了一半。

避坑指南(这些坑我全部踩过真实)

坑1:ControlNet的module和model对应不上

第一次用OpenPose,加载了canny的模型。错误描述是:骨架图起效了30%,姿势大概对,但手指关节神秘地融到一起。原因是ControlNet内部逻辑是module定义预处理,model定义注入模型。你preprocessor检测姿势骨架,但model不识别骨架,等于传给UNet的是垃圾特征。检查方法:WebUI的预览图能正常生成骨架不等于模型对了,看设置里是否有[cab9d2b5][fef5e248]后缀。我犯过一次用openpose模块+seg模型,输出图完全没有结构,白跑200张。

坑2:LoRA权重拉满,画面过曝

刚开始觉得LoRA越强越像角色,把权重设到1.2,结果生成图里兔子的机械耳朵轮廓发糊,红色LED灯环变成了血红色块。LoRA的权重学习的是「特征偏差」,拉满会让特征过拟合。我实测0.85是最甜的,0.7开始角色特征开始漂移。如果你用多个LoRA叠加,建议分别降到0.6,否则画面会堆砌两种风格而且互相污染。

坑3:Canny阈值和预处理器分辨率不同步

Canny提取边缘图,我一开始用的是UI里的默认值,low 100 high 200。但当生成分辨率为1024x1024时,处理器分辨率必须是1024。你如果设512,提取到的边缘线很细,放大到1024时边缘线条出现毛刺,ControlNet疯狂尝试拟合噪声边缘,结果图背景出现不必要的结构线。这个问题在UI上不容易发现,因为预览图看着正常。

坑4:bash脚本里base64换行导致API 500

这是我这篇文章最开始遇到的第一个报错。用base64 -w 0可以避免换行符。如果你用base64 file.png直接得到的是带换行的,curl会把换行符塞进JSON,导致解析失败,服务端直接500。

另一个反直觉的问题:用API传input_image字段,如果图片很大(超过2MB base64),SD WebUI会卡在解析JSON上长达10秒。解决方案是先把边缘图压到512KB以内,也就是把单色PNG转成8-bit索引色,体积可以减小70%。

坑5:多GPU并发时API端口冲突

我用两个GPU,第一张卡跑了python launch.py --listen --port 7860,第二张跑了--port 7861。但第二个实例启动时调用了相同的控制端口,导致第一个实例的API偶发超时。实际做法是第二张卡加--api-only --port 7861,同时确保--listen不是0.0.0.0:7860这样它不会绑定全局。如果还是冲突,就把api端口设为7862再试。

结语:这套流程能用在什么场景

ControlNet + LoRA组合,本质上把SD从「创意抽卡机」变成了「以图生图的定点生产工具」。现在我再去接产品图外包,流程都是:设定图抠干净 → 训练LoRA → 准备姿势骨架/Canny边缘 → 批量API出图。控制角色、控制姿势、控制产出比例。SD仍然会偶尔给你惊喜,但惊喜不再是随机发生的事情,而是你通过ControlNet的weight和LoRA的trigger word精确控制出来的。

附:完整配置清单(可直接复制保存)

# sd_production_config.yaml
# 我跑完全部45张图的最终配置,存为备份
hardware:
  gpu: "NVIDIA GeForce RTX 4090"
  vram: 24
  cpu: "AMD EPYC 7R32" 
  ram: 32G

software:
  python: "3.10.14"
  torch: "2.1.2+cu121"
  webui: "Forge 2024-06-15"
  controlnet_plugin: "1.1.441"
  base_model: "Realistic Vision v6.0"

generation:
  width: 768
  height: 768
  steps: 28
  cfg_scale: 4.5
  sampler: "DPM++ 2M Karras"
  batch_size: 1
  seed: 42

controlnet_units:
  - module: "openpose"
    weight: 0.8
    guidance_start: 0
    guidance_end: 1
  - module: "canny"
    threshold_a: 100
    threshold_b: 200
    weight: 0.6
    guidance_start: 0
    guidance_end: 0.8

lora:
  model: "xinggui_v1.safetensors"
  weight: 0.85
  trigger_word: "xinggui"

这份配置我重新跑过,稳定复现,没有任何玄学参数。拿过去就能用。