// learn it by watching it happen
From pure noise
to a picture.
Stable Diffusion looks like magic. It isn't. It's one small idea (teach a network to remove a little noise), repeated until structure appears. This guide builds your intuition first, then the math, then real PyTorch you can run. Drag the slider below to feel the whole field in one gesture.
Left to right is the forward process (easy: just add Gaussian noise). The model only ever learns to take one step right-to-left. Chain those steps and you generate.
why generation is hard
The problem: drawing from a distribution you can't write down
A 512×512 RGB image is ~786,000 numbers. Almost every possible combination of those numbers is static. The images that look like something (faces, dogs, mountains) sit on a vanishingly thin manifold inside that enormous space.
"Generating an image" means sampling a new point that lands on that manifold. We never get an equation for it. Instead we collect millions of real images and train a model to learn the shape of the data, well enough to invent new points that belong.
Imagine a dark room where every real photo is a glowing dot, and all dots cluster on a few invisible sheets of paper folded through the dark. Your job: drop a new dot that lands on the paper, not in the empty dark. Diffusion is a method for walking a random dot onto the paper, one nudge at a time.
Older approaches attacked this directly. GANs pit a generator against a critic (powerful but unstable, prone to mode collapse). VAEs learn a smooth compressed code (stable but blurry). Diffusion won the image race because it turns one impossibly hard jump into thousands of trivially easy steps.
the one idea everything rests on
Destroy it slowly, then learn to rebuild it
Drop ink in water. It diffuses into a uniform cloud, and you can write down exactly how. Running that backwards, reassembling the drop, is the hard part. Diffusion models exploit an asymmetry: the destruction is free and known; we only pay to learn the reversal.
Two chains over the same points. Top adds noise; bottom is the learned denoiser. Train on top, generate on bottom.
Reversing a giant leap from noise to a photo is hopeless. But reversing one tiny step (an image that's 99% clean to one that's 100% clean) is an easy, well-posed prediction. We trade a single impossible problem for a thousand easy ones the same network solves at every noise level.
the forward process, precisely
Noising on a schedule, and the shortcut that makes training cheap
At each step we mix in a little Gaussian noise. How much is set by a fixed noise schedule β₁…β_T (tiny at first, larger later). One step:
xₜ = √(1−βₜ)·xₜ₋₁ + √(βₜ)·ε ε ~ 𝒩(0, I)
They're not arbitrary. The signal you keep and the noise you add behave like the two perpendicular legs of a right triangle whose hypotenuse stays pinned at 1, because (√1−βₜ)² + (√βₜ)² = 1. So the image's total "energy" (its variance) never grows or shrinks across all 1000 steps. Pixels can't explode to white or fade to gray. This is variance preservation, and it's what keeps the chain stable.
Doing 1000 steps to build one training example would be painfully slow. The key fact: a sum of Gaussians is still Gaussian, so we can jump to any timestep t in one shot. Define αₜ = 1−βₜ and the cumulative product ᾱₜ = α₁·α₂···αₜ. Then:
xₜ = √(ᾱₜ)·x₀ + √(1−ᾱₜ)·ε
That single line is the entire data pipeline. ᾱₜ is just "how much original image survives": it slides from ≈1 (clean) to ≈0 (pure noise). It's exactly the blend you were dragging in the hero.
Drag t (or press play) and watch the point slide down the arc from pure signal to pure noise. The two legs trade length, but the point never leaves the unit circle: that fixed radius is variance preservation, drawn. The white tick in the schedule strip below follows the same t.
Adding a thousand small, independent Gaussian nudges is statistically the same as taking one bigger Gaussian step, because variances simply add, like a random walk whose final spread you can predict without tracing every footstep. That's the reparameterization trick: it lets us compute the noisy image at step t directly, instead of looping 1000 times for every training example.
The wandering path and the single arrow land in the same bell-curve spread. We compute the destination, not the journey.
The noise schedule visualized: the curve is ᾱₜ (how much image survives at step t), and each strip segment is √ᾱ signal blended with √(1−ᾱ) noise. Toggle the schedule: cosine holds ᾱ high for longer, so mid-chain steps still contain trainable signal; the original linear schedule destroys most information early. The white marker follows the t slider above.
import torch def cosine_schedule(T, s=0.008): # smoother than linear; preserves detail at low t (Nichol & Dhariwal 2021) t = torch.linspace(0, T, T+1) f = torch.cos(((t / T + s) / (1 + s)) * torch.pi / 2) ** 2 abar = f / f[0] betas = (1 - abar[1:] / abar[:-1]).clamp(1e-5, 0.999) return betas betas = cosine_schedule(1000) alphas = 1. - betas abar = torch.cumprod(alphas, dim=0) # ᾱₜ for every t def q_sample(x0, t, eps): # jump straight to noise level t: the whole training pipeline a = abar[t].sqrt().view(-1,1,1,1) am = (1 - abar[t]).sqrt().view(-1,1,1,1) return a * x0 + am * eps
the objective, and why it's so simple
What the network actually learns: "which way is realistic?"
You might expect a complicated objective. Instead, we hand the network a noisy image xₜ and its timestep t, and ask it to answer one question: what noise did I add? Predict ε. That's it.
L = 𝔼[ ‖ ε − εθ(xₜ, t) ‖² ]
Mean-squared error between the true noise and the network's guess. If it can name the noise, subtracting a slice of it nudges the image toward the clean manifold.
Training rotates the guess (εθ) until it lands on the real hidden noise (ε). The loss is just the squared length of the pink gap: just "how far off was I?"
Predicting the noise is, up to a constant, predicting the score: the gradient ∇ log p(x), the direction in pixel space that most increases "realness." The network is a compass. At every noise level it points: this way toward real images. Generation is just following the compass downhill, repeatedly. Diffusion, score matching, and the SDE view are three languages for this one fact.
The score field, made literal. Every arrow points toward the data manifold (the gold ring); they're longest where the model is most "lost." Generation drops a random white dot into the static and lets it follow the arrows home, exactly what sampling (§06) does, step by step.
And the training loop is shockingly short: sample an image, sample a random timestep, noise it, predict, backprop:
for x0 in dataloader: # x0: a batch of real images t = torch.randint(0, T, (x0.size(0),)) # random noise level per image eps = torch.randn_like(x0) # the noise we will hide xt = q_sample(x0, t, eps) # noisy version (closed form) pred = model(xt, t) # εθ: guess the noise loss = F.mse_loss(pred, eps) # that's the whole objective loss.backward(); opt.step(); opt.zero_grad()
Every difference between this toy and Stable Diffusion is about scale and control, not a different idea. The objective above never really changes.
the body of the denoiser
The U-Net: see the whole image and every pixel at once
The denoiser must output a value per pixel, yet to denoise sensibly it needs global context (is this a cat's ear or a cloud?). The U-Net resolves this tension: an encoder that shrinks the image while widening understanding, a decoder that rebuilds full resolution, and skip connections that hand fine detail straight across so it isn't lost in compression.
A U-Net with self-attention at the bottleneck. The same network handles every timestep; t is fed in as an embedding so it knows "how noisy" the input is.
Conv → norm → activation with a residual skip. The workhorse that processes features at each resolution.
The timestep becomes a sinusoidal vector (like positional encoding), passed through an MLP and added so the net adapts its behavior to the noise level.
At low resolutions, every location attends to every other; this is how distant parts of an image stay globally coherent.
Stable normalization for small batches and a smooth activation: the unglamorous glue that makes training converge.
How the network knows how noisy the input is
The timestep t is a single integer, but feeding a raw number in works poorly. Instead we expand it into a sinusoidal embedding: a vector of sines and cosines at many frequencies, the same trick transformers use for word positions.
PE(t)[2i] = sin( t / 100002i/d ) PE(t)[2i+1] = cos( t / 100002i/d )
Top: each column is one timestep's embedding; each row is one frequency (slow at top, fast at bottom), like a bank of clock hands turning at different speeds. Bottom: the exact column your slider selects (solid bars) against the embedding 40 steps later (outlines). The slow dimensions barely move between neighbors while the fast ones spin, so nearby timesteps share most of their fingerprint and far-apart ones share almost none. That is why the network generalizes smoothly across noise levels instead of memorizing each one.
class ResBlock(nn.Module): def __init__(self, c_in, c_out, t_dim): super().__init__() self.norm1 = nn.GroupNorm(8, c_in) self.conv1 = nn.Conv2d(c_in, c_out, 3, padding=1) self.temb = nn.Linear(t_dim, c_out) # inject timestep self.norm2 = nn.GroupNorm(8, c_out) self.conv2 = nn.Conv2d(c_out, c_out, 3, padding=1) self.skip = nn.Conv2d(c_in, c_out, 1) if c_in!=c_out else nn.Identity() def forward(self, x, t): h = self.conv1(F.silu(self.norm1(x))) h = h + self.temb(t)[:, :, None, None] # broadcast t over H,W h = self.conv2(F.silu(self.norm2(h))) return h + self.skip(x)
turning a denoiser into a generator
Sampling: walk back from static, one step at a time
To generate, start from pure noise x_T and apply the trained denoiser repeatedly. At each step: predict the noise, remove a calibrated slice of it, and add back a touch of fresh randomness (this keeps outputs diverse rather than collapsing to one blurry average).
xₜ₋₁ = 1/√αₜ · ( xₜ − βₜ/√(1−ᾱₜ) · εθ(xₜ,t) ) + σₜ·z z~𝒩(0,I)
xₜ − [βₜ/√(1−ᾱₜ)]·εθSubtract the noise you found. The network names the noise hiding in xₜ; peel off a calibrated slice of it to reveal a slightly cleaner image.1/√αₜ · ( … )Rescale. Undo the shrink the matching forward step applied, so brightness and contrast stay consistent.+ σₜ·zRe-inject a little randomness. Without this the model drifts toward one blurry average; the small jitter keeps samples sharp and varied. (It's dropped on the very last step.)One reverse step, geometrically: from the noisy point xₜ, estimate where the clean image x̂₀ lies, step a small fraction of the way there, then add a tiny random wiggle. Repeat ~T (or ~25 with DDIM) times and you land on the manifold.
Faithful to the math, stochastic, but slow: needs hundreds–thousands of steps because it mirrors the training chain exactly.
Reframes sampling as deterministic. Estimate the clean image, jump ahead. Same model, 20–50 steps, near-identical quality.
DDIM skips along a smooth trajectory instead of taking every micro-step, like integrating an ODE with a bigger step size.
@torch.no_grad() def ddpm_sample(model, shape): x = torch.randn(shape) # start: pure noise x_T for t in reversed(range(T)): # walk T → 0 eps = model(x, torch.full((shape[0],), t)) a, ab, b = alphas[t], abar[t], betas[t] mean = (x - b/(1-ab).sqrt() * eps) / a.sqrt() x = mean + (b.sqrt() * torch.randn_like(x) if t > 0 else 0) return x # a fresh sample on the manifold
Below is that exact loop running live. For a 2-D point cloud the true score is computable in closed form, so this is the real reverse process (the same math as sample.py), not a canned animation. Every run starts from fresh noise and lands somewhere new on the gold manifold.
White dots start as pure Gaussian noise. Each step asks the score "which way is realistic?", peels off a slice of noise, and (in DDPM) re-injects a little randomness. Watch the DDPM trails jitter their whole way home while DDIM glides in smooth, and drop the step count to see why DDIM barely cares: it is integrating a smooth trajectory, not retracing every micro-step.
from random images to "an astronaut on a horse"
Control: telling the model what to draw
So far the model generates something from the data, but not what you asked for. Control means feeding a condition c alongside the noisy image so the denoiser becomes εθ(xₜ, t, c). For text, three pieces click together:
1 · Encode the words
A frozen text encoder (CLIP, or T5 in newer models) turns your prompt into a sequence of vectors: a numerical summary of meaning the U-Net can read.
2 · Cross-attention injects meaning
Inside the U-Net's attention blocks, the image features form the queries while the text vectors supply keys and values. Each image region asks "which words am I responsible for?" and pulls in their guidance. This is the literal bridge from language to pixels.
Attn(Q,K,V) = softmax( Q·Kᵀ / √d ) · V Q=image, K,V=text
Q·KᵀMatch-making scores. Dot every image region against every word: the score is high when a region and a word are relevant to each other./ √dKeep the scores tame. Dividing by √(dimension) stops big vectors from producing huge numbers that would jam the next step.softmax( … )Turn scores into focus. Squash each region's scores into weights that sum to 1: a little probability distribution over which words to listen to.… · VPull the meaning in. Blend the word vectors by those weights and inject the result into the image features.Attention for the prompt "a red fox in snow." Hover a word to light up the image regions it steers, or hover the picture to see which words that region listens to. Each row sums to 1: that routing is how language steers pixels.
3 · Classifier-free guidance: the "prompt strength" dial
During training, drop the text ~10% of the time so the same network can run conditioned and unconditioned. At sampling, compute both and push away from the generic toward the prompted:
ε̂ = εθ(xₜ,t,∅) + s·( εθ(xₜ,t,c) − εθ(xₜ,t,∅) )
The unconditioned prediction is "any plausible image." The conditioned one is "an image that fits the prompt." Their difference is a vector that means more like what you asked. Scale s stretches that vector: s≈1 is loose and creative, s≈7–8 is the sweet spot, s≈20 over-bakes: saturated, rigid, sometimes broken. That single slider in every UI is this equation.
Guidance as arithmetic, live. Top: the teal difference (cond − uncond) points "toward the prompt," and the gold ε̂ keeps walking in that direction past the conditioned point as you raise s. Bottom: what that does to the distribution of images the sampler can reach. The guided density (gold) slides toward prompt-perfect territory, then sharpens into a spike: obedience up, diversity gone. That spike is the over-baked look every UI warns you about.
the trick that put it on consumer GPUs
Latent diffusion: don't denoise pixels, denoise a compressed sketch
Running diffusion on full 512×512 pixels is brutally expensive. Stable Diffusion's key move: compress first. A pretrained autoencoder (VAE) squeezes the image ~8× per side into a small latent tensor (e.g. 64×64×4 instead of 512×512×3, roughly a 48× reduction in elements). Do all the diffusion there, then decode once at the end.
The full Stable Diffusion pipeline: encode → diffuse in latent space (with text via cross-attention) → decode. Everything from §02–07 happens in the middle box.
The VAE throws away perceptual redundancy (the imperceptible high-frequency wiggle) while keeping structure and semantics. The diffusion model spends its capacity on what matters (composition, content) instead of regenerating texture noise pixel by pixel. Compression turned a research demo into something that runs on a laptop GPU. This one decision is most of "why Stable Diffusion specifically."
Stack the pieces and you have the real system: VAE (compress/decompress) + U-Net (denoise latents) + text encoder (condition) + scheduler (the sampling loop). Nothing in the earlier sections changed; it all just moved into a smaller space.
adding a time axis
Into video: keep frames consistent across time
A video is a stack of frames. Denoise each one independently and you get flicker: every frame invents its own details. The fix is to let frames talk to each other so motion is smooth and objects stay the same object.
Insert attention/convolutions that operate along the time axis between the existing spatial layers. Each pixel now attends to itself across neighboring frames.
Freeze a trained image model and train only a small "motion module" of temporal layers. You inherit a strong image prior and just teach it to move.
Compress in time as well as space, so a clip becomes a compact spatiotemporal latent: the video analog of §08.
Full 3D attention is too costly, so split it: attend over space, then over time. Cheaper, and most coherence comes from the temporal pass.
Factorized attention: one pass keeps each frame internally coherent, the other keeps a point consistent as time advances. Together they remove flicker.
The cutting edge (Sora-class systems) drops the U-Net for a Diffusion Transformer over spacetime patches of a 3D latent, but conceptually it's still §02's idea: denoise a compressed representation, now with time folded in.
where the field is going + your path
The frontier, and the order to build it yourself
Two shifts define modern systems (SD3, Flux, Sora-class). Worth knowing so you're learning the current thing, not the 2021 thing:
Replace the U-Net with a transformer over image patches. Scales more predictably with data and compute; now standard at the high end.
Instead of the discrete noise chain, learn a straight-line velocity field from noise to data. Fewer steps, cleaner training. Same compass idea, smoother road.
But you learn this by climbing a ladder, smallest rung first. Each rung is runnable in an afternoon to a weekend and proves you understood the one before:
DDPM on MNIST
Tiny U-Net, §03 schedule, §04 loss, §06 DDPM sampler. When fuzzy digits emerge from noise, you've understood the core. ~150 lines.
CIFAR-10 + DDIM + class labels
Go to color, add self-attention, swap in DDIM for fast sampling, and condition on a class label. Your first controlled generation.
Train (or borrow) a VAE → latent diffusion
Move diffusion into latent space (§08). Use a pretrained VAE so you can focus on the U-Net. This is the real Stable Diffusion shape.
Text conditioning + classifier-free guidance
Plug in a frozen CLIP encoder, add cross-attention (§07), train with prompt dropout, expose the guidance dial. Now you have text-to-image.
Temporal module for short clips
Freeze your image model, add temporal attention (§09), train on a small video set. Watch a coherent few-second clip appear.
Modernize
Re-implement the backbone as a DiT and switch the objective to flow matching. You're now building what the labs build.
A diffusion model is a denoiser that learned, at every noise level, which direction points toward real data, and generation is just following that compass from static all the way home. Everything else (U-Net vs DiT, pixels vs latents, image vs video, text control) is engineering around that single sentence.