LAB // CANVAS EXPANSION · 2026

OUTPAINT.SYS

A conditional diffusion model trained from scratch to extend images beyond their borders — one 128-pixel window at a time. It learned to hide the seam almost perfectly. It has not yet learned to remember what it was painting.

diffusionv-predictionmasked DDIMU-Net 92MDIV2K + Flickr2KPyTorch
92M
U-Net params
128px
model canvas
250k
train steps · done
3,450
training images
30
DDIM steps / tile
19.2GB
peak VRAM · RTX 3090
01 · THE SEAM PROBLEM

Growing an image sideways

Cropping an image is easy. Un-cropping it means inventing pixels that never existed — and the first pixel you invent sits right next to one that did.

WHY NAIVE EXTENSION FAILS

The border is a cliff

Mirroring, clamping or blurring the edge outward produces wallpaper, not world. A real continuation has to extend structure — the branch keeps bending, the fur keeps flowing, the lighting stays consistent. That requires a generative model that actually understands what the visible pixels imply about the invisible ones.

CONDITIONING

Seven channels in, three out

The ADM-style U-Net sees the noisy canvas (3ch), the clean known pixels (3ch) and the mask (1ch) stacked together. It was trained on random crops of DIV2K + Flickr2K with a curriculum of masks — mostly one-sided bands, because outpainting is the job — predicting v with min-SNR-5 weighting on a cosine schedule.

HIDING THE SEAM

Keep the known region noisy

The trick that makes seams disappear: during sampling, the known region is re-noised onto the forward trajectory at every DDIM step, so known and unknown pixels always share a noise level. Training reinforces it from the other side — loss is computed over the whole canvas with known pixels down-weighted to 0.1 instead of masked to zero, giving the boundary gradient signal from both directions.

THE FAILURE MODE

Drift: the model forgets

Recursive expansion only ever sees the last window of committed pixels. Sixteen tiles in, the original subject has scrolled out of context and the panorama free-associates — a lion becomes weather. Halving the stride to keep 75% context doubles the compute and still drifts. This is the open problem.

02 · PIPELINE

One expansion, start to finish

The loop below runs once per committed tile — the interactive sim further down replays sixteen of them back to back.

EXPAND_LOOP.FLOW
1
WINDOW + MASK

Slide a 128px window over the canvas frontier. Pixels already committed are 'known' (mask=1); the strip to invent is 'unknown' (mask=0). The webapp picks whichever 4×4-tile window covers the target cell with the most accepted context.

2
RE-NOISE THE KNOWN REGION

At every sampling step the known pixels are pushed back onto the forward diffusion trajectory at the current noise level, so the network always sees a canvas that looks like its training data — no clean/noisy mismatch at the seam.

3
CONDITIONAL DENOISE

The U-Net reads 7 channels — noisy canvas, clean context, mask — and predicts v. 30 DDIM steps walk pure noise down to an image that agrees with the context on structure, color and grain.

4
CANDIDATE BATCH

Diffusion is stochastic: the same context with 4 different seeds gives 4 different futures. Candidates are sampled as one batch, so extra options are nearly free on the GPU.

5
SELECT + COMMIT

A human picks the best candidate (the webapp's accept/undo loop). Known pixels are composited back exactly — the model never gets to repaint what's already committed.

6
ADVANCE + REPEAT

Shift the window by the stride (64px default, 32px keeps more context) and go again. Each pass grows the canvas; nothing bounds how far it can extend — except drift.

03 · NUMBERS

What the run actually looked like

Full 250k-step training run on a single RTX 3090 — batch 20, BF16, 1.77 it/s, about 1.6 days of GPU time.

LOSS.LOG
0.010.030.10.31001k10k100k250kWARMUP CLIFFLONG FLAT GRIND ↓0.013→0.011TRAINING STEP (LOG)V-PRED LOSS (LOG)
real data — min-SNR-weighted v-prediction loss from runs/full/log.jsonl (19 of 2,500 records shown). Nearly all visible progress happens before step 25k; the remaining 225k steps buy subtle texture quality that the loss barely registers.
MASK_CURRICULUM.CFG
ONE-SIDED BAND65%CORNER10%EDGE BLOCK10%INTERIOR / IRREGULAR10%EMPTY (UNCONDITIONAL)5%SHARE OF TRAINING MASKS
real data — the mask sampler mix in data.py. Bands dominate because outpainting is the target task; 5% fully-empty masks teach the model to generate seed tiles from nothing.
STRIDE_TRADEOFF.CMP
STRIDE 64 (DEFAULT)STRIDE 3264px96pxCONTEXT KEPT64px32pxNEW PX / STEP1632PASSES / +1024PXPER 128PX WINDOW, RECURSIVE RIGHTWARD EXPANSION
derived— arithmetic from sample.py's expand loop (context = canvas − stride). Halving the stride keeps 75% context per window instead of 50%, at exactly 2× the sampling cost — and in practice both settings still drift off-subject after enough tiles.
04 · EVIDENCE

Real outputs, unretouched

Everything below came out of the trained checkpoint via sample.py on held-out DIV2K-valid images the model never saw in training.

EVAL_CANDIDATES_5.PNG
Candidate grid: a palm frond with its right half blacked out, followed by four different model-generated completions of the missing half

Candidate batch. Leftmost: the context — a palm frond with the right half masked (shown black). Then four completions from four seeds over the identical context. Each invents a different but plausible continuation of the frond; this one-context-many-futures operation is the core primitive the interactive webapp is built on.

EVAL_CANDIDATES_12.PNG
Candidate grid: a busy scene with a purple glove, right half masked, and four generated completions with varying invented content

Harder context.Cluttered scene, purple glove. The seeds diverge much more here — some completions stay abstract, one invents new objects. When context under-determines the continuation, candidate variety goes up; that's exactly when having a human pick (or, someday, a scorer) matters most.

EVAL_EXPAND_8.PNG — STRIDE 64 × 8 TILES
A 640 pixel wide panorama grown from a 128 pixel plant photo: leaves continue plausibly then become invented foliage

Progressive expansion, 8 tiles.Grown 128→640px from the left. The first strips genuinely continue the plant; by the right edge the foliage is pure invention but still on-theme. Note there is no visible seam at any of the eight commit boundaries — the re-noising trick doing its job.

EVAL_EXPAND_16.PNG — STRIDE 64 × 16 TILES
A 1152 pixel wide panorama starting from a lion's face that gradually drifts into abstract gray shapes

Drift, exhibit A.Same recipe pushed to 16 tiles (128→1152px), seeded with a lion's face. Around tile 4 the lion has left the 64px context window and the panorama starts free-associating: fabric, weather, a red something. Locally every seam is clean; globally the plot is lost. This strip is the one you can replay in EXPANDER.SIM below.

EVAL_EXPAND_16_STRIDE32(_PLANT).PNG — STRIDE 32 × 16 TILES
A 640 pixel wide stride-32 panorama from the same lion image, drifting into gray abstraction more slowly
A 640 pixel wide stride-32 panorama from the plant image, staying on-theme longer before inventing new content

Stride experiment.Same 16 tiles, but committing only 32 new pixels per pass (75% context kept, 2× the compute per distance). Both the lion and the plant hold coherence a little longer per pixel travelled — and both still end somewhere the seed image never implied. Smaller strides slow drift; they don't stop it.

05 · INTERACTIVE

EXPANDER.SIM

Watch the canvas grow tile by tile. Start from the 128px seed crop and press EXPAND to commit the next strip — exactly as the model generated it. Switch strides to compare how fast each run walks away from the lion.

EXPANDER.SIM — REPLAY OF A REAL RUN
Panorama generated by the outpainting model, 128 of 1152 pixels revealed
CANVAS 128pxTILES 0/16CTX 64px

replay mode: every strip you reveal is real model output — a stored 16-tile run of sample.py --mode expand on a DIV2K-valid crop (seed 128px, 30 DDIM steps per tile). The browser is only cropping the finished panorama; live in-browser sampling via ONNX export is a roadmap item. Watch the subject drift as the context window walks away from the original pixels.

06 · STATUS

Ledger

Honest accounting: what exists and runs, what is unresolved, what comes next.

■ BUILT
  • Full training run complete: 250k steps, 92M params, EMA checkpoints at 6 milestones.
  • Masked DDIM sampler with forward-trajectory re-noising and exact known-pixel compositing.
  • Candidate + recursive-expansion demos (sample.py), evaluated on held-out DIV2K-valid.
  • FastAPI webapp: unbounded tile canvas, click-to-expand, 4-candidate picker, accept/undo — runs locally against the checkpoint.
  • Multi-scale JPEG crop cache (8.9 GB) so Windows dataloader workers keep the 3090 fed.
? OPEN QUESTIONS
  • Automatic candidate scoring. Today a human picks; no metric yet distinguishes a good continuation from a plausible-looking wrong one.
  • Drift. Content decoheres once the subject leaves the context window, at stride 64 and stride 32 alike. Long-range memory or global conditioning is unsolved here.
  • Seam metrics: seams look invisible, but there's no quantitative boundary-artifact measure beyond eyeballing.
» NEXT
  • ONNX export of the EMA U-Net so EXPANDER.SIM can sample live in the browser instead of replaying stored runs.
  • Anchor experiments against drift: keep a downscaled global canvas as an extra conditioning channel.
  • Scale past the 128px canvas — either a bigger window or coarse-to-fine expansion.
< back_to_labmore experiments →