ControlNet, LoRA & Conditioning
Text alone is a clumsy control signal. ControlNet lets you clone a pretrained diffusion model and steer it with a depth map, pose skeleton, scribble, or edge image. LoRA lets you fine-tune a 2B-parameter model by training 10 million parameters. Together they turned Stable Diffusion from a toy into a standard 2026 image pipeline.
Learning Objectives
- Explain how ControlNet adds spatial conditioning to a frozen diffusion backbone through a cloned encoder and zero-convolution connections.
- Write the LoRA update W + alpha * B @ A and compare its parameter count to a full d x d fine-tune.
- Choose among ControlNet, LoRA, IP-Adapter, Textual Inversion, DreamBooth, and T2I-Adapter for a given control problem.
- Avoid the common stacking failures: over-scaled LoRAs, conflicting ControlNet weights, and LoRAs loaded on the wrong base.
The Problem
A prompt like "a woman in a red dress walking a dog on a busy street" gives the model no information about where the dog is, what pose the woman is in, or the perspective of the street. Text pins down about 10% of what you need to specify an image. The rest is visual and cannot be described efficiently in words.
Training a new conditional model from scratch for each signal (pose, depth, canny, segmentation) is prohibitive. The alternative is to keep the 2.6B-param SDXL backbone frozen, attach a small side-network that reads the conditioning, and let it nudge the backbone's intermediate features. That is ControlNet.
Teaching the model new concepts (your face, your product, your style) runs into the same cost problem, because retraining the full model is too expensive. You want a delta about 100x smaller. That is LoRA: low-rank adapters that plug into existing attention weights.
ControlNet, LoRA, and text together make up the 2026 practitioner's toolkit. Most production image pipelines layer 2-5 LoRAs, 1-3 ControlNets, and an IP-Adapter on top of an SDXL / SD3 / Flux base.
The Concept
ControlNet (Zhang et al., 2023)
ControlNet is a trainable clone of a pretrained Stable Diffusion U-Net's encoder that accepts an extra conditioning input (edges, depth, pose) and feeds its features into the original decoder. To build one, take a pretrained SD, clone the encoder half of the U-Net, and freeze the original. Train the clone so it accepts the conditioning input. Connect it back to the decoder half of the original through zero-convolution skip connections: 1×1 convs initialized to zero, so they start as a no-op and learn a delta.
SD U-Net decoder: ... ← orig_enc_features + zero_conv(controlnet_enc(condition))
Because of the zero-conv init, ControlNet starts as identity and does no harm even before training. Train it on about 1M (prompt, condition, image) triples with the standard diffusion loss.
Per-modality ControlNets ship as small side models (~360M for SDXL, ~70M for SD 1.5). You can compose them at inference:
features += weight_a * control_a(depth) + weight_b * control_b(pose)
LoRA (Hu et al., 2021)
LoRA (low-rank adaptation) freezes a pretrained weight matrix and learns a low-rank delta that is added on top of it. For any linear layer W ∈ R^{d×d} in the model, add:
W' = W + ΔW, ΔW = B @ A, A ∈ R^{r×d}, B ∈ R^{d×r}
with r << d. Initialize B to zeros (as main.py does) and ΔW starts at zero, so a fresh LoRA is also a no-op, the same trick as zero-conv. Rank 4-16 is standard for attention, rank 64-128 for heavy fine-tunes. The new parameter count is 2 · d · r instead of d². For SDXL attention with d=640 and r=16, that is 20k params per adapter instead of 410k, a 20x reduction. Across the whole model, a LoRA is usually 20-200MB against a 5GB base.
At inference you can scale the LoRA: W' = W + α · B @ A. α = 0.5-1.5 is normal. Multiple LoRAs stack additively, though they interact in non-linear ways.
IP-Adapter (Ye et al., 2023)
An IP-Adapter is a tiny adapter that accepts an image as conditioning alongside text. It uses the CLIP image encoder to produce image tokens and injects them into cross-attention next to the text tokens. It costs ~20MB per base model and lets you generate an image in the style of a reference without a LoRA.
Composability matrix
| Tool | What it controls | Size | When to use |
|---|---|---|---|
| ControlNet | Spatial structure (pose, depth, edges) | 70-360MB | Exact layout, composition |
| LoRA | Style, subject, concept | 20-200MB | Personalization, style |
| IP-Adapter | Style or subject from reference image | 20MB | No text can describe the look |
| Textual Inversion | Single concept as a new token | 10KB | Legacy, mostly replaced by LoRA |
| DreamBooth | Full fine-tune on a subject | 2-5GB | Strong identity, high compute |
| T2I-Adapter | Lighter ControlNet alternative | 70MB | Edge devices, inference budget |
ControlNet ≈ spatial, LoRA ≈ semantic. Use both.
Build It
code/main.py simulates the two mechanisms in pure Python:
-
LoRA. Take a pretrained linear layer
Wand freeze it. Train a low-rankB @ Aso thatW + BAmatches a target layer whose difference fromWis a rank-1 matrix. The script uses 6×6 matrices, triesr = 1, 2, 4, and prints the residual MSE for each. All three print 0.00000, sor = 1is already enough for a rank-1 correction (the script prints the residual but does not assert it). -
ControlNet-lite. A "frozen base" predictor plus a "side network" that reads an extra signal. A learnable scalar gate, initialized to zero (our version of zero-conv), scales the side network's output. This part is scalar. Train and watch the gate and the side weight move together toward the target.
Step 1: LoRA math
The snippet below is illustrative pseudocode. The real code is lora_forward, which computes (W + alpha * B @ A) @ x with a plain matrix-vector helper.
def lora(W, A, B, x, alpha=1.0):
# W is frozen; A, B are the trainable low-rank factors.
return [W[i][j] * x[j] for i, j in ...] + alpha * (B @ (A @ x))Step 2: zero-init side network
side_out = control_net(x, condition)
gated = gate * side_out # gate initialized to 0
h = base(x) + gatedAt step 0 the output is identical to base, so the branch cannot disturb it before training starts. w_side starts small but nonzero, so gate gets a gradient immediately, while w_side gets none until gate has left zero. After that they climb together toward a product near the 0.7 in the target. The printout shows only steps 100, 300, 500, and 700, and by step 100 the gate is already near 0.8, so print the first few steps yourself to see the ramp.
Pitfalls
- Over-scaling LoRAs.
α = 2orα = 3is a common "make it stronger" hack, and it produces over-stylized or broken outputs. Keepα ≤ 1.5. - ControlNet weight conflict. A Pose ControlNet at weight 1.0 plus a Depth ControlNet at weight 1.0 usually overshoots. A sum of weights around 1.0 is a safe default.
- LoRA on the wrong base. SDXL LoRAs silently no-op on SD 1.5 because the attention dimensions do not match. Diffusers warns about this in 0.30+.
- Textual Inversion drift. Tokens trained on one checkpoint drift badly on another. LoRA is more portable.
- LoRA weight-merging and storage. You can bake a LoRA into the base model weights for faster inference (no runtime addition), but then you lose the ability to scale
αat runtime. Keep both versions.
Use It
| Goal | 2026 pipeline |
|---|---|
| Reproduce a brand's art style | LoRA trained on ~30 curated images at rank 32 |
| Put my face in a generated image | DreamBooth or LoRA + IP-Adapter-FaceID |
| Specific pose + prompt | ControlNet-Openpose + SDXL + text |
| Depth-aware composition | ControlNet-Depth + SD3 |
| Reference + prompt | IP-Adapter + text |
| Exact layout | ControlNet-Scribble or ControlNet-Canny |
| Background replace | ControlNet-Seg + Inpainting (Lesson 09) |
| Fast 1-step style | LCM-LoRA on SDXL-Turbo |
Ship It
Save outputs/skill-sd-toolkit-composer.md. The skill takes a task (input assets: prompt, optional reference image, optional pose, optional depth, optional scribble) and outputs the tool stack, weights, and a reproducible seed protocol.
I'd run main.py before touching diffusers and read the ControlNet-lite rows with the zero start in mind. The gate begins at 0, so at step 0 the output equals the base. By the first printed row (step 100) the gate and w_side have both left zero and are moving together toward a product near the 0.7 in the target. The printout skips the early steps, so I'd print the first few myself to see the ramp. Starting a new side branch at zero is a habit I'd keep.
My bet is that rank is the part of the LoRA half that carries furthest. The target delta in main.py is rank-1, and r of 1, 2, and 4 all fit it, with the residual printing as 0.00000 each time. The takeaway line says rank has to be at least the rank of the true delta, and that is the first thing I'd check on a LoRA that underfits, before touching α.
When stacking, I'd keep the weights modest and keep LoRAs unmerged. Merging buys ~3-5% per-step speed and costs you runtime control of α. Each active ControlNet costs roughly 1.5× per step, so if latency is the problem, I'd look at the ControlNets before I merged anything.
Exercises
- Easy. In
code/main.py, the target delta is rank-1 and the script triesrin[1, 2, 4]. Change the delta to a sum of two outer products (rank 2) and rerun. At what rank does the residual MSE reach about zero? - Medium. Train two separate LoRAs on two target transforms. Load them together and show their additive interaction. When does the interaction break linearity?
- Hard. Use diffusers to stack: SDXL-base + Canny-ControlNet (weight 0.8) + a style LoRA (α 0.8) + IP-Adapter (weight 0.6). Measure FID-vs-prompt-adherence trade-off as the stack weights vary.
Key Terms
| Term | What people say | What it actually means |
|---|---|---|
| ControlNet | "Spatial control" | Cloned encoder + zero-conv skips; reads a conditioning image. |
| Zero convolution | "Starts as identity" | 1×1 conv initialized to zero; ControlNet starts as no-op. |
| LoRA | "Low-rank adapter" | W + B @ A, r << d; 100x fewer params than a full fine-tune. |
| rank r | "The knob" | LoRA compression; 4-16 typical, 64+ for heavy personalization. |
| α | "LoRA strength" | Runtime scaling of the LoRA delta. |
| IP-Adapter | "Reference image" | Small image-conditioning adapter via CLIP-image tokens. |
| DreamBooth | "Full subject fine-tune" | Train the full model on ~30 images of a subject. |
| Textual Inversion | "New token" | Learn a new word embedding only; legacy, mostly replaced. |
Production note: LoRA swaps, ControlNet lanes, multi-tenant serving
A real text-to-image SaaS serves hundreds of LoRAs and a dozen ControlNets over the same base checkpoint. The serving problem looks a lot like LLM multi-tenancy (the production literature covers the LLM case under continuous batching and LoRAX / S-LoRA):
- Hot-swap LoRAs, don't merge. Merging
W' = W + α·B·Ainto the base gives ~3-5% faster per-step inference but freezesαand the base. Keep LoRAs hot in VRAM as rank-r deltas. Diffusers exposespipe.load_lora_weights()pluspipe.set_adapters([...], adapter_weights=[...])for per-request activation. A swap costs the2 · d · r · num_layersweights: MB-scale, sub-second. - ControlNet as a second attention lane. The cloned encoder runs in parallel with the base. Two ControlNets at weight 1.0 each mean two extra forward passes per step, not one merged pass, and batch-size headroom drops quadratically. Budget ~1.5× step cost per active ControlNet.
- Quantize the LoRAs too. If you quantized the base (see Lesson 07, Flux on 8GB), the LoRA delta also quantizes cleanly to 8-bit or 4-bit. QLoRA-style loading lets you stack 5-10 LoRAs on top of a 4-bit Flux base without blowing memory.
Flux-specific: Niels' Flux-on-8GB notebook quantizes the base to 4-bit, and stacking a style LoRA (pipe.load_lora_weights("user/style-lora")) on that quantized base at weight_name="pytorch_lora_weights.safetensors" still works. This is the recipe most SaaS agencies ship in 2026.
Further Reading
- Zhang, Rao, Agrawala (2023). Adding Conditional Control to Text-to-Image Diffusion Models: ControlNet.
- Hu et al. (2021). LoRA: Low-Rank Adaptation of Large Language Models: LoRA (originally for LLMs; ports to diffusion).
- Ye et al. (2023). IP-Adapter: Text Compatible Image Prompt Adapter: IP-Adapter.
- Mou et al. (2023). T2I-Adapter: Learning Adapters to Dig Out More Controllable Ability: lighter alternative to ControlNet.
- Ruiz et al. (2023). DreamBooth: Fine Tuning Text-to-Image Diffusion Models for Subject-Driven Generation: DreamBooth.
- HuggingFace Diffusers: ControlNet / LoRA / IP-Adapter docs: reference pipelines.
Code
- main.py (python, 112 lines)