< /lab
LAB // IMAGE GENERATION

DIFFUSION FROM SCRATCH

A denoising diffusion model (DDPM) written in ~250 lines of plain PyTorch and trained on CIFAR-10 — no diffusers library, no pretrained weights, no hidden machinery. It learns to turn static into tiny photographs, and the whole network exports to ONNX so it can eventually run right here in your browser.

DDPMU-NetPyTorchDDIMONNX / WebGPU
18.7M
U-Net params
T=1000
noise steps
50
DDIM sample steps
~4H
on one RTX 3090

01 · THE IDEA

Destroy, then learn to undo

Diffusion models are built on a trick that sounds backwards: the only thing the network ever learns is how to clean up noise.

▓▓▒▒░░

1 · FORWARD: DROWN THE IMAGE

Take a real photo and add a little Gaussian noise, 1000 times, following a fixed schedule (β ramps linearly from 1e-4 to 0.02). By the end nothing survives — it's TV static. The useful part: you never have to noise step-by-step. A closed-form shortcut jumps straight to any timestep t.

x_t = √·x₀ + √(1−)·ε
? → ε

2 · LEARN: GUESS THE NOISE

The training loop is almost embarrassingly simple: pick a photo, pick a random t, noise it with the shortcut, and ask a U-Net — which sees the noisy image plus an embedding of t — to predict exactly which noise was added. The loss is plain MSE between the guess and the truth. That's it. Repeated 117,000 times, that one task forces the network to learn what images look like.

░░▒▒▓▓ ⇤

3 · SAMPLE: RUN IT BACKWARDS

To generate, start from pure noise and repeatedly ask the network "what noise is in this?" — subtract its estimate to get a guess at the clean image, re-noise to a slightly earlier timestep, and repeat. DDIM makes this deterministic and takes 50 strides instead of 1000 steps. An image no one has ever seen condenses out of static.

"From scratch" here means every piece — the U-Net, the noise schedule, the timestep embeddings, EMA weight averaging, mixed-precision training, the DDIM sampler — is written out by hand in train_diffusion.py, following the original DDPM paper (Ho et al., 2020) rather than importing a library that hides it.

02 · PIPELINE

Six moving parts

Everything below ships in four small Python files. Numbers are from the actual training run, not a spec sheet.

PIPELINE.MAP
1
DATASET — CIFAR-10

50,000 tiny 32×32 photos in 10 classes. Random horizontal flips, pixels normalized to [−1, 1], batches of 128 (390 batches per epoch). Small enough to train honestly on one consumer GPU.

2
NOISE SCHEDULE — LINEAR β, T=1000

β ramps from 1e-4 to 0.02 across 1000 timesteps;_t is precomputed as the cumulative product of (1−β). Charted for real below.

3
U-NET — 18.7M PARAMETERS

Three resolution levels (32→16→8) with 128/256/256 channels, residual blocks fed sinusoidal timestep embeddings, self-attention at 16² and 8², GroupNorm + SiLU, dropout 0.1. Skip connections carry fine detail across the bottleneck.

4
TRAINING LOOP — PREDICT ε, MSE LOSS

Adam at 2e-4 with AMP mixed precision; an EMA copy of the weights (decay 0.9995) is what actually generates. 300 epochs ≈ 117k steps, about 4 hours on a single RTX 3090, final loss ≈ 0.029. Checkpoints are resumable.

5
SAMPLER — DDIM, 50 STEPS

Deterministic sampling: at each of 50 timesteps the model predicts the noise, the loop reconstructs a clamped estimate of x₀, then re-noises it to the next timestep down. 20× fewer steps than naive DDPM sampling with no perceptible quality loss at this scale.

6
ONNX EXPORT — BROWSER-READY

export_onnx.py exports the EMA U-Net at opset 17 (75 MB) and verifies it against PyTorch to <1e-3 max abs difference. ONNX Runtime Web runs it on WebGPU — a 50-step sample takes about 2 seconds client-side, with a WASM fallback.

03 · INSTRUMENTS

Two curves that explain everything

The schedule decides how fast images drown; the loss curve shows why you should never trust it as a progress bar.

NOISE_SCHEDULE.SVG
0250500750100000.250.50.751TIMESTEP tFRACTIONᾱt — surviving signal√(1−ᾱt) — noise level50 DDIM sampling steps

Computed, not sketched: this is the exact schedule from train_diffusion.py— β linear from 1e-4 to 0.02 over T=1000, ᾱt= ∏(1−β). By t≈350 less than half the signal survives; by t=999 only 0.004% does. The mustard dots are the 50 timesteps DDIM actually visits when sampling.

TRAIN_LOSS.SVG
0501001502002503000.0000.0250.0500.0750.100EPOCH (300 EPOCHS ≈ 117K STEPS)MSE LOSSplateau ≈ 0.03EP 10mushEP 100objectsEP 300final

Representative curve— per-epoch losses were printed to console during the run, not logged to disk. The anchors are real: loss collapses to ≈0.03 within about five epochs and the run finished at ≈0.029. The honest lesson: for diffusion, loss is a nearly useless progress signal — it flatlines while the samples below keep improving for another 295 epochs. Judge by the grids.

04 · SAMPLES.LOG

Watching a model learn to see

Uncurated 8×8 grids sampled from the same training run as it progressed — every grid is DDIM, 50 steps, EMA weights. No cherry picking.

EP010.PNG
Uncurated 8 by 8 grid of CIFAR-10 samples at training epoch 10
epoch 10 · ~3.9k stepstextured mush — the model knows local color statistics, nothing else
EP050.PNG
Uncurated 8 by 8 grid of CIFAR-10 samples at training epoch 50
epoch 50 · ~19.5k stepsblobs acquire horizons and figure/ground separation
EP100.PNG
Uncurated 8 by 8 grid of CIFAR-10 samples at training epoch 100
epoch 100 · ~39k stepsrecognizable objects: cars, birds, horses start to resolve
EP300.PNG
Uncurated 8 by 8 grid of CIFAR-10 samples at training epoch 300
epoch 300 · ~117k stepsfinal model — the same one behind the scrubber below

Between epoch 10 and epoch 300 the training loss moved by roughly 0.002. The images did not get the memo — which is exactly the point made by the loss chart above.

05 · INTERACTIVE

Scrub the trajectory yourself

Real samples from the trained model, real schedule math, one honest shortcut — the fine print is under the panel.

DENOISER.SIM — TRAJECTORY SCRUBBER
LOADING…
timestep t
999 / 999
ᾱ_t (signal kept)
0.0000
√(1−ᾱ) (noise)
1.000
signal 1%noise 100%
t=999 · pure noiset=0 · sample

What's real: the pixels (samples from the trained model), the ᾱ schedule, and the 50-step trajectory GENERATE walks through. What's simulated:the denoising itself — this scrubber renders the closed-form forward process q(x_t | x) in reverse rather than running the U-Net 50 times. Want the real thing? The network is exported to ONNX and deployed — it samples live on WebGPU at the demo Space ↗. Bringing that loop onto this page is the roadmap item below.

06 · STATUS.TXT

Ledger

LAB/DIFFUSION — STATE OF PLAY
BUILTFull training stack in plain PyTorch: U-Net, DDPM schedule, EMA, AMP, resumable checkpoints, DDIM sampler.
BUILTThe 300-epoch CIFAR-10 run (≈117k steps, ~4h on an RTX 3090) — every grid on this page comes from it.
BUILTONNX export with a PyTorch parity check, a one-button web generator, and a static WebGPU sampling page (DDIM loop in ~30 lines of JS) — deployed and running publicly at the demo Space ↗. Code on GitHub ↗, weights and unet.onnx on the Hub ↗.
SIM ONLYDENOISER.SIM above visualizes the forward process in reverse over real samples; it does not run the network in your browser.
NEXTBring the hosted WebGPU sampler onto this page, so GENERATE runs the real 50-step DDIM loop here rather than linking out to it. The model, the export and the sampling page all exist and work; what remains is serving the 75 MB U-Net from this domain and swapping the sim for the real loop.