从一次崩溃的外包项目说起
上个月接了个潮玩公司的外包单:给一款盲盒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.3 | Windows比例少一批插件兼容性,生产建议Linux |
| GPU | RTX 4090 24GB(也可用2080Ti 22G) | 实测最低15G显存能跑 |
| Python | 3.10.14 | 3.11对Torch算子是完整的,但xformers老版本会报错,固定3.10 |
| PyTorch | 2.1.2+cu121 | 版本往上走反而有坑 |
| SD WebUI | Forge 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.32 | 0.58 | 6% | 1.9s(每张要重复抽6次) | 8.2GB |
| LoRA + 提示词 | 0.34 | 0.73 | 15% | 2.1s(重复4次) | 9.1GB |
| ControlNet only | 0.79 | 0.62 | 74% | 1.4s(一次生成) | 9.8GB |
| ControlNet + LoRA | 0.83 | 0.87 | 81% | 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"
这份配置我重新跑过,稳定复现,没有任何玄学参数。拿过去就能用。