Code Flow Reference — a step-by-step cheat sheet for understanding how a Stable Diffusion pipeline is built from raw diffusers components.
Every tutorial below maps 1-to-1 to a script in this repo and follows the exact execution order of the code. Read top-to-bottom and you read the pipeline.
Logic. We do not use StableDiffusionPipeline. Each weight set is loaded individually via from_pretrained(..., subfolder=...), moved to GPU, and cast to float16 for ~2× speed and ~50% VRAM savings. eval() disables dropout. The scheduler chosen here is DPM-Solver++ with Karras sigmas — it converges in far fewer steps than DDIM.
Logic. Stable Diffusion does not denoise pixels — it denoises a 4×64×64 latent. The VAE encoder shrinks 512×512×3 → 4×64×64, and outputs are scaled by the magic constant 0.18215 so that latents have roughly unit variance (this is what the UNet was trained on). The mask must be downsampled to the same 64×64 latent grid and broadcast across the 4 channels.
python
1defencode_to_latent(self, init_image):2 preprocess = transforms.Compose([3 transforms.Resize((512,512)),4 transforms.ToTensor(),5 transforms.Normalize([0.5],[0.5]),# → range [-1, 1]6])7 input_tensor = preprocess(init_image).unsqueeze(0).to(self.device, dtype=self.dtype)89with torch.no_grad():10 latents = self.autoencoder.encode(input_tensor).latent_dist.sample()1112return latents *0.18215# 🔑 VAE scaling factor — DO NOT FORGET
Step 3 — Text & Noise Prep (generate_noise_from_prompt)
Logic. Classifier-Free Guidance (CFG) requires two forward passes per step: one with the prompt, one with an empty prompt. The trick is to do both in a single batched UNet call by concatenating embeddings along the batch axis → shape [2, 77, 768].
python
1# 1. Conditional (positive) prompt2text_inputs = self.tokenizer(prompt, padding="max_length",3 max_length=self.tokenizer.model_max_length, return_tensors="pt")4text_embeddings = self.text_encoder(text_inputs.input_ids.to(self.device)).last_hidden_state
56# 2. Unconditional (empty) prompt — drives negative guidance7uncond_inputs = self.tokenizer("", padding="max_length",8 max_length=self.tokenizer.model_max_length, return_tensors="pt")9uncond_embeddings = self.text_encoder(uncond_inputs.input_ids.to(self.device)).last_hidden_state
1011# 3. Stack them → one UNet call handles both branches in parallel12text_embeddings = torch.cat([uncond_embeddings, text_embeddings])# [2, 77, 768]1314# 4. Base Gaussian noise — same shape as the latent15init_noise = torch.randn(latent_shape, device=self.device, dtype=self.dtype)
🔑 Cheat sheet: Order matters → [uncond, cond]. You'll chunk(2) in the same order during the loop.
Step 4 — The Core Hack: Denoising Loop (blend_latent_with_mask)
Logic. At every timestep we re-noise the clean original latent up to the current t and use it as the background. The masked foreground is the latent actively being denoised. Spatial blending at every step is what keeps the unmasked region pixel-faithful to the original.
Logic. Reverse Step 2. Undo the 0.18215 scale, run the VAE decoder, rescale [-1, 1] → [0, 1], then re-arrange tensor axes to (H, W, C) for PIL.Image.fromarray.
🔑 Cheat sheet:permute(0, 2, 3, 1) = (B, C, H, W) → (B, H, W, C) before PIL.
Step 6 — Execution (main)
python
1blended = BlendedLatentDiffusion()2output = blended.blended_latent_diffusion(3 init_image=PIL.Image.open("input.jpg"),4 mask_image=PIL.Image.open("mask.png"),# white = edit region5 prompt="fluffy white clouds in a bright blue sky, highly detailed",6 num_inference_steps=25,7 strength=0.95,# Full overwrite of masked area8 guidance_scale=12.0,# Strong prompt adhesion9)10output.save("output_image.jpg")
Knob-tuning cheat sheet (blended_loop)
Parameter
Range
Effect
num_inference_steps
20–50
More = higher quality, slower. DPM++ converges fast — 25 is a sweet spot.
strength
0.0–1.0
How far back in the noise schedule we start. 1.0 = pure noise inside mask.
guidance_scale
1.0–15.0
CFG weight. 7.5 standard. Higher = more prompt-faithful, more saturated.
mask (white)
binary
Region that will be regenerated. Black = preserved.
The premise. Standard CFG adds one positive pull toward the prompt. Concept Erasure adds N negative pulls — one per unwanted concept — so the model actively avoids hallucinating each of them. This is how you stop a "forest road at night" from sprouting streetlights and headlights it was never asked for.
Pipeline overview (parallel to Tutorial 01; the novel logic is in Step 3 and Step 5):
Same component contract as Tutorial 01 — VAE, CLIP tokenizer + text encoder, UNet, DPM++ Karras scheduler, float16 + eval() on CUDA. Nothing new at this layer; the technique is implemented entirely in how we batch text and combine noise predictions.
Identical to encode_to_latent from Tutorial 01: resize → [-1, 1] normalize → VAE encode → multiply by 0.18215. No mask in this technique — the entire image is up for revision.
Step 3 — Batched Text Embeddings (generate_noise_from_prompts)
🔑 This is where the technique starts. We stack [uncond, cond, erase_1, erase_2, …, erase_N] along the batch axis so a single UNet forward pass yields all noise predictions in parallel.
🔑 Cheat sheet: the order [uncond, cond, *erase] is a contract — you'll chunk(2 + N) in the loop and access [0], [1], [2:] in exactly that order.
Step 4 — Setup Timesteps & Inject Noise
Same strength-based slicing pattern as Tutorial 01. unsqueeze(0) makes the start timestep a 1-D tensor so add_noise doesn't trip on a 0-D scalar with DPM++.
Step 5 — The Core Hack: Multi-Negative Guidance Loop
Every step the UNet runs on a batched input of 2 + N copies of the same latent, each paired with a different text context. We split predictions into uncond, cond, and erase_1…N, then add the prompt direction and subtract each erasure direction.
python
1total_chunks =2+len(erasure_prompt)23for t in timesteps:4# (A) Broadcast latents to the (2+N) batch so they pair with each text context5 latent_model_input = torch.cat([latents]* total_chunks)6 latent_model_input = self.scheduler.scale_model_input(latent_model_input, t)78# (B) ONE UNet call → all (2+N) noise predictions in parallel9 noise_pred = self.unet(latent_model_input, t,10 encoder_hidden_states=text_embeddings).sample
1112# (C) Split predictions in the SAME order they were stacked13 all_preds = noise_pred.chunk(total_chunks)14 noise_pred_uncond = all_preds[0]15 noise_pred_cond = all_preds[1]16 noise_pred_erase = all_preds[2:]# tuple of N tensors1718# (D) 🔑 Standard CFG — pull TOWARD the prompt19 guided_noise = noise_pred_uncond + guidance_scale *(noise_pred_cond - noise_pred_uncond)2021# (E) 🔑 Concept Erasure — push AWAY from each banned concept22for noise_pred_e in noise_pred_erase:23 guided_noise -= erase_scale *(noise_pred_e - noise_pred_uncond)2425# (F) Step down to the next noise level26 latents = self.scheduler.step(guided_noise, t, latents).prev_sample
🔑 Cheat sheet — the equations that define this technique:
Equation
Standard CFG
noise = u + s · (c − u)
Erasure term(per banned concept eᵢ)
noise −= wᵢ · (eᵢ − u)
Combined
noise = u + s·(c − u) − Σᵢ wᵢ·(eᵢ − u)
Geometric intuition. Each (x − u) is a direction vector in noise-prediction space pointing from "neutral" to that concept. CFG adds the prompt direction; erasure subtracts each banned direction. erase_scale is the magnitude of repulsion per banned concept.
Step 6 — Decoding (decode_from_latent)
Identical contract to Tutorial 01 — note this version squeeze(0)s the batch dim before permute(1, 2, 0), instead of permuting and indexing [0]:
1ce = ConceptErasure()2init_image = PIL.Image.open("scene_erasure.png").convert("RGB")34prompt ="A road at night in the forest"5erasure_prompt =["Streetlights","Headlights","Tail lights","Lamps","Artificial lights"]67result = ce.concept_erasure(8 init_image=init_image,9 prompt=prompt,10 erasure_prompt=erasure_prompt,11 num_inference_steps=50,12 strength=0.3,# Light denoise — preserve overall scene13 guidance_scale=7.5,# Normal CFG strength14 erase_scale=10.0,# 🔑 Aggressive repulsion from banned concepts15)16result.save("output_concept_erased.jpg")
Knob-tuning cheat sheet (concept_erasure)
Parameter
Range
Effect
strength
0.0–1.0
Re-denoise depth. 0.3 keeps structure intact while suppressing concepts. High strength may destroy scene composition.
guidance_scale
1.0–15.0
Strength of the positive prompt pull.
erase_scale
1.0–15.0
🔑 The new knob. Strength of the negative pull per erasure prompt. Higher = stronger erasure but more artifacts.
erasure_prompt
list[str]
One concept per entry. Each adds +1 to the UNet batch size at every step.
Tutorial 03 — DDIM Inversion (inversion_implemention.py)
The premise. Standard generation goes noise → image. DDIM Inversion runs the same denoising network in reverse — walking timesteps 0 → T instead of T → 0 — to recover the exact noise that would, when denoised, produce a given real image. That noise becomes a deterministic handle you can later re-denoise with a different prompt → the foundation of all real-image editing techniques.
Pipeline overview. Two loops, one UNet, two schedulers:
Logic. Two schedulers, one UNet. DDIMInverseScheduler is built via .from_config() of the forward DDIMScheduler so they share the exact same α-bar schedule — this symmetry is what makes inversion mathematically reversible.
python
1self.unet = UNet2DConditionModel.from_pretrained(self.model_name, subfolder="unet")2self.vae = AutoencoderKL.from_pretrained(self.model_name, subfolder="vae")34# 1. Standard scheduler for generation (T → 0)5self.noise_scheduler = DDIMScheduler.from_pretrained(self.model_name, subfolder="scheduler")6# 2. 🔑 Inverse scheduler for inversion math (0 → T) — same config = same α-schedule7self.inverse_scheduler = DDIMInverseScheduler.from_config(self.noise_scheduler.config)
🔑 Cheat sheet: DDIM is the only standard SD scheduler that's deterministically invertible. DPM++, Euler, LMS — none of them round-trip cleanly.
Step 2 — Latent Encoding (get_latent_image)
Logic. Two important differences from Tutorial 01's encoder — both critical for a faithful round-trip:
python
1init_latents = self.vae.encode(image_tensor).latent_dist.mode()# 🔑 .mode(), not .sample()2init_latents = init_latents * self.vae.config.scaling_factor # 🔑 from config, not hardcoded
🔑 Cheat sheet:
.mode() returns the deterministic mean of the VAE's posterior — no random draw, so the round-trip is reproducible. .sample() would inject noise on encode and break inversion.
vae.config.scaling_factor == 0.18215 for SD-v1.4, but reading from config is robust across model variants.
Step 3 — Text Embeddings (get_text_embeddings)
Logic. Just one embedding — no uncond, no CFG. Pure DDIM inversion is deterministic and runs a single text context. (Variants like Null-Text Inversion re-introduce CFG and optimize the uncond embedding — separate technique, separate tutorial.)
text_embeddings = self.text_encoder(**inputs).last_hidden_state # [1, 77, 768] — no uncond
Step 4 — The Forward Loop: Inversion (ddim_invers)
Logic. Walk timesteps 0 → T. At each step, predict noise with the UNet, then ask the inverse scheduler to push the latent one step further into noise.
python
1self.inverse_scheduler.set_timesteps(num_inference_steps, device=self.device)2timesteps = self.inverse_scheduler.timesteps # 🔑 ascending: 0 → ~99934latents = init_latents.clone()5for idx, t inenumerate(timesteps):6 noise_pred = self.unet(latents, t, encoder_hidden_states=text_embeddings).sample
78# 🔑 .step() on the INVERSE scheduler walks FORWARD in time.9# The field is still called .prev_sample but it's now the NEXT (noisier) state.10 latents = self.inverse_scheduler.step(noise_pred, t, latents).prev_sample
🔑 Cheat sheet — leaky abstraction watch:DDIMInverseScheduler.step().prev_sample is misnamed — for the inverse scheduler it means "next-step output". The diffusers API reuses the field name; only the direction of travel reverses.
Step 5 — The Reverse Loop: Sampling (ddim_sampling)
Logic. Identical control flow, but with the forward scheduler. Start from the inverted noise (or any noise of the right shape) and walk T → 0 to recover an image.
🔑 Cheat sheet: Same UNet, same prompt, opposite scheduler. The pair (invert → sample) should reconstruct the input up to small floating-point drift. If it doesn't → debug your encode (.mode()?), your scheduler (DDIM-only?), or your prompt (must match the source).
Step 6 — Decoding (vae_decoder)
Standard VAE round-trip — divide by 0.18215, decode, rescale, permute, return PIL.
1pipeline = InversionImplementationDDIM()23# 1. Inversion: real image → noise4inverted_noise, inversion_visuals = pipeline.ddim_invers(5 num_inference_steps=50,6 init_image="Road_in_Norway.jpg",7 prompt="a photo of a road in norway",8 visual_steps=[0,1,2],# capture early-step latents for debugging9)1011# 2. Sampling: noise → reconstructed image12reconstructed_image, sampling_visuals = pipeline.ddim_sampling(13 num_inference_steps=50,14 inverted_latents=inverted_noise,15 prompt="a photo of a road in norway",16 visual_steps=[0,1,2],17)18reconstructed_image.save("reconstructed_final.jpg")
🔑 Cheat sheet: The reconstruction quality is your inversion's report card. If the round-trip image differs visibly from the input → check (1) .mode() on encode, (2) DDIM schedulers on both sides, (3) same prompt + same step count both directions.
Editing flow
Inversion alone reconstructs. To edit, change the prompt during the sampling call:
python
1inverted, _ = pipeline.ddim_invers (50,"road.jpg", prompt="a photo of a road in norway")2edited, _ = pipeline.ddim_sampling(50, inverted, prompt="a photo of a snowy road in norway")
Knob-tuning cheat sheet (inversion)
Parameter
Range
Effect
num_inference_steps
50–200
More steps = more faithful round-trip. Per-step error is smaller but compounds across more steps — a tradeoff.
prompt
str
Must describe the source image accurately. A mismatched prompt biases the inverted noise and degrades reconstruction.
visual_steps
list[int]
Indices to capture for debugging the inversion trajectory.
The premise. Cross-attention maps inside the UNet encode which pixels each word of the prompt is paying attention to. If you save every cross-attention map from a source run, then overwrite them on a target run with a slightly different prompt — keeping the random seed identical — the spatial layout of the source carries over while the target prompt repaints the content within that layout.
No retraining. No extra forward passes. One tensor patched at the right place in the UNet's forward pass.
Pipeline overview. Two runs, same noise seed, two different attention processors:
What is an attention processor? Every transformer block in the diffusers UNet routes its attention through a swappable AttnProcessor. The default one does standard QKV math. By subclassing and registering your own via unet.set_attn_processor(...), you get a hook inside every attention computation — and can either observe Q/K/V/probs or mutate them.
Cross-attention vs Self-attention. Inside __call__:
is_cross_attention = encoder_hidden_states is not None
Self-attention → encoder_hidden_states is None → image attending to itself, controls texture/coherence.
Cross-attention → encoder_hidden_states is CLIP text embeddings → image attending to text, controls what is where. This is the only one P2P touches.
Step 1 — The Save Processor
Logic. Run the full standard attention computation, but right after computing attention_probs, snapshot the cross-attention probability matrix into a list.
python
1classSaveCrossAttnProcessor:2def__init__(self):3 self.attention_maps =[]45def__call__(self, attn, hidden_states, encoder_hidden_states=None, attention_mask=None,**kwargs):6# Standard QKV math (Q from image, K/V from text for cross-attn)7 query = attn.head_to_batch_dim(attn.to_q(hidden_states))89 is_cross_attention = encoder_hidden_states isnotNone10ifnot is_cross_attention:11 encoder_hidden_states = hidden_states
1213 key = attn.head_to_batch_dim(attn.to_k(encoder_hidden_states))14 value = attn.head_to_batch_dim(attn.to_v(encoder_hidden_states))1516# Attention probabilities — shape [B·heads, seq_image, seq_text] for cross-attn17 attention_probs = attn.get_attention_scores(query, key, attention_mask)1819# 🔑 The SAVE: only cross-attention, detached copy20if is_cross_attention:21 self.attention_maps.append(attention_probs.detach().clone())2223# Standard tail: weighted sum + output projection24 hidden_states = torch.bmm(attention_probs, value)25 hidden_states = attn.batch_to_head_dim(hidden_states)26 hidden_states = attn.to_out[0](hidden_states)27return hidden_states
.detach().clone() → detach from autograd, clone so the original tensor's storage can be freed by the next step.
The order of saved maps is: outer = timestep, inner = layer-by-layer in UNet forward order. The inject processor consumes them in exactly the same order.
Step 2 — The Inject Processor
Logic. Identical QKV computation, but right before the weighted sum, replace the freshly computed attention_probs with the corresponding saved map.
🔑 Note on injection_ratio. In the current implementation, the field is stored but unused — every step where a saved map exists gets overridden. To get the canonical P2P behavior (inject only in the first N% of steps so the model can refine texture freely at the end), change the condition to:
Early-step attention controls layout; late-step attention refines texture. Limiting injection to early steps preserves the source's geometry while letting the target prompt repaint detail.
Step 3 — Execution (main)
Logic. Two pipeline calls, same locked seed. Between them, swap the processor on the UNet.
python
1pipe = StableDiffusionPipeline.from_pretrained(2"runwayml/stable-diffusion-v1-5", torch_dtype=torch.float16
3).to(device)45prompt_source ="A driving dashcam view of a sunny road in Norway"6prompt_target ="A driving dashcam view of a snowy road in Norway"78# ─── Source run ─────────────────────────────────────────9generator = torch.manual_seed(42)# 🔑 LOCK SEED10save_processor = SaveCrossAttnProcessor()11pipe.unet.set_attn_processor(save_processor)# 🔑 inject the SAVE hook12source_image = pipe(prompt_source, generator=generator, num_inference_steps=50).images[0]1314# ─── Target run ─────────────────────────────────────────15generator = torch.manual_seed(42)# 🔑 SAME SEED → same initial noise16inject_processor = InjectCrossAttnProcessor(saved_maps=save_processor.attention_maps)17pipe.unet.set_attn_processor(inject_processor)# 🔑 swap to INJECT hook18target_image = pipe(prompt_target, generator=generator, num_inference_steps=50).images[0]
🔑 Why the seed lock matters. P2P relies on the initial latent noise being identical between runs. Different noise → different geometry from step 1 → the saved cross-attention maps no longer correspond to anything in the target run's spatial layout.
🔑 Why the target prompt should be a minimal edit. P2P only carries over spatial layout. If prompt_target is structurally very different from prompt_source (changing nouns, verbs, and composition at once), the injected maps will fight the target prompt and you'll get artifacts. Word-level swaps and adjective changes → clean results.
Knob-tuning cheat sheet (prompt-to-prompt)
Parameter
Range
Effect
seed
int
Must match between source and target runs. Different seed = broken layout transfer.
injection_ratio
0.0–1.0
Fraction of steps to inject. Lower = looser layout, more target-texture freedom. Currently dead code — patch as shown above.
prompt_target
str
Should be a minimal edit of prompt_source (one or two word swaps).
num_inference_steps
20–50
Must match between runs so saved_maps indexing lines up.
Where to extend this
Word-level swap maps — instead of dumping every map, weight specific source-word columns ("sunny") onto specific target-word columns ("snowy") of the probs.
Map reweighting — scale specific text-token columns up or down to amplify/suppress concepts without swapping prompts.
Layer-selective injection — only inject at certain UNet resolutions (low-res down-blocks for global layout, high-res up-blocks for detail).
CUDA GPU recommended (CPU works in float32 but is slow)
Why this repo exists
Most tutorials wrap StableDiffusionPipeline and call .generate(). This repo does the opposite: every script rebuilds a capability from its raw building blocks so that the loop, the scheduler, the CFG math, and the VAE contract are all visible and editable. If you can read these scripts end-to-end, you can modify any diffusion pipeline.
License
Educational reference. Model weights (CompVis/stable-diffusion-v1-4, openai/clip-vit-large-patch14) follow their respective licenses on the Hugging Face Hub.