nanye commited on
Commit
45769da
·
1 Parent(s): 3030398

- use python backend with an actual DDPM model

Browse files

- progress status
- add more controls
- simple compact UI

Files changed (7) hide show
  1. README.md +23 -0
  2. app.js +134 -295
  3. generate.py +146 -0
  4. index.html +36 -101
  5. requirements.txt +5 -0
  6. server.py +122 -0
  7. styles.css +70 -179
README.md ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Diffusion Lab
2
+
3
+ The reverse process runs the pretrained `google/ddpm-celebahq-256` model through Hugging Face Diffusers. This is an unconditional CelebA-HQ face generator: reverse sampling starts with random Gaussian noise and does not denoise the uploaded image. The upload is used only by the browser-side forward-noising demonstration.
4
+
5
+ ## Run
6
+
7
+ Use Python 3.10 or newer. Install a PyTorch build appropriate for your machine first if its default install needs a platform-specific wheel, then install the app dependencies and start the server:
8
+
9
+ ```sh
10
+ python -m venv .venv
11
+ source .venv/bin/activate
12
+ python -m pip install -r requirements.txt
13
+ python server.py
14
+ ```
15
+
16
+ Open <http://127.0.0.1:8000>. The first generation downloads the model weights into `models/`; later runs use that cache. The backend prefers CUDA, then Apple Silicon MPS, then CPU. Model inference uses all 1,000 DDPM steps and sends the selected intermediate images to the browser as they are produced.
17
+
18
+ To generate and display one image without starting the server, run:
19
+
20
+ ```sh
21
+ python generate.py
22
+ python generate.py --seed 1234
23
+ ```
app.js CHANGED
@@ -1,325 +1,164 @@
1
  const TOTAL_STEPS = 1000;
2
- const CHECKPOINTS = [0, 50, 100, 200, 350, 500, 700, 1000];
 
3
  const SIZE = 192;
4
  const PIXEL_COUNT = SIZE * SIZE * 3;
5
-
6
  const elements = {
7
- sourceCanvas: document.querySelector('#source-canvas'),
8
- stateCanvas: document.querySelector('#state-canvas'),
9
- outputCanvas: document.querySelector('#output-canvas'),
10
- sourceName: document.querySelector('#source-name'),
11
- stateKicker: document.querySelector('#state-kicker'),
12
- stateCorner: document.querySelector('#state-corner'),
13
- stateTitle: document.querySelector('#state-title'),
14
- stateSubtitle: document.querySelector('#state-subtitle'),
15
- outputStamp: document.querySelector('#output-stamp'),
16
- outputTitle: document.querySelector('#output-title'),
17
- outputSubtitle: document.querySelector('#output-subtitle'),
18
- stepSlider: document.querySelector('#step-slider'),
19
- stepReadout: document.querySelector('#step-readout'),
20
- checkpointStrip: document.querySelector('#checkpoint-strip'),
21
- playButton: document.querySelector('#play-button'),
22
- imageUpload: document.querySelector('#image-upload'),
23
- explanationTitle: document.querySelector('#explanation-title'),
24
- explanationCopy: document.querySelector('#explanation-copy'),
25
- scrubberLeft: document.querySelector('#scrubber-left'),
26
- scrubberRight: document.querySelector('#scrubber-right'),
27
  };
28
-
29
  let sourcePixels = new Float32Array(PIXEL_COUNT);
30
- let forwardNoise = new Float32Array(PIXEL_COUNT);
31
  let forwardStates = [];
32
- let reverseStates = [];
33
- let activeMode = 'forward';
34
- let activeCheckpoint = 0;
35
- let animationTimer = null;
36
  let imageLabel = 'Still life 01';
 
 
 
 
 
 
 
 
37
  const schedule = makeSchedule();
38
 
39
  function makeSchedule() {
40
- const betas = new Float64Array(TOTAL_STEPS);
41
- const alphas = new Float64Array(TOTAL_STEPS);
42
- const alphaBars = new Float64Array(TOTAL_STEPS);
43
- let product = 1;
44
- for (let step = 0; step < TOTAL_STEPS; step += 1) {
45
- betas[step] = 0.0001 + (0.02 - 0.0001) * step / (TOTAL_STEPS - 1);
46
- alphas[step] = 1 - betas[step];
47
- product *= alphas[step];
48
- alphaBars[step] = product;
49
- }
50
- return { betas, alphas, alphaBars };
51
  }
52
-
53
  function randomNormal(seed) {
54
  let state = seed >>> 0;
55
- return () => {
56
- state = (state + 0x6D2B79F5) | 0;
57
- let value = state;
58
- value = Math.imul(value ^ value >>> 15, value | 1);
59
- value ^= value + Math.imul(value ^ value >>> 7, value | 61);
60
- const uniform = ((value ^ value >>> 14) >>> 0) / 4294967296;
61
- return Math.sqrt(-2 * Math.log(Math.max(uniform, 1e-7))) * Math.cos(2 * Math.PI * uniform);
62
- };
63
  }
64
-
65
  function makeSourceArt() {
66
- const canvas = document.createElement('canvas');
67
- canvas.width = SIZE;
68
- canvas.height = SIZE;
69
- const context = canvas.getContext('2d');
70
- const sky = context.createLinearGradient(0, 0, SIZE, SIZE);
71
- sky.addColorStop(0, '#dce7db');
72
- sky.addColorStop(0.58, '#b8c7ad');
73
- sky.addColorStop(1, '#a2b5a0');
74
- context.fillStyle = sky;
75
- context.fillRect(0, 0, SIZE, SIZE);
76
- context.fillStyle = '#e9d6b5';
77
- context.beginPath();
78
- context.arc(147, 44, 24, 0, Math.PI * 2);
79
- context.fill();
80
- context.fillStyle = '#52765f';
81
- context.beginPath();
82
- context.moveTo(0, 137);
83
- context.quadraticCurveTo(50, 91, 99, 137);
84
- context.quadraticCurveTo(145, 109, 192, 132);
85
- context.lineTo(192, 192);
86
- context.lineTo(0, 192);
87
- context.closePath();
88
- context.fill();
89
- context.fillStyle = '#315b48';
90
- context.beginPath();
91
- context.moveTo(0, 159);
92
- context.quadraticCurveTo(61, 127, 112, 164);
93
- context.quadraticCurveTo(155, 143, 192, 157);
94
- context.lineTo(192, 192);
95
- context.lineTo(0, 192);
96
- context.closePath();
97
- context.fill();
98
- context.fillStyle = '#d8795d';
99
- context.beginPath();
100
- context.ellipse(92, 144, 24, 31, -0.22, 0, Math.PI * 2);
101
- context.fill();
102
- context.fillStyle = '#e8a37c';
103
- context.beginPath();
104
- context.ellipse(84, 136, 14, 21, -0.22, 0, Math.PI * 2);
105
- context.fill();
106
- context.fillStyle = '#496c52';
107
- context.beginPath();
108
- context.ellipse(103, 111, 21, 10, -0.62, 0, Math.PI * 2);
109
- context.fill();
110
- context.beginPath();
111
- context.ellipse(77, 109, 16, 8, 0.52, 0, Math.PI * 2);
112
- context.fill();
113
- context.strokeStyle = '#f6ebd3';
114
- context.lineWidth = 2;
115
- context.beginPath();
116
- context.moveTo(27, 171);
117
- context.quadraticCurveTo(62, 158, 89, 178);
118
- context.stroke();
119
- context.fillStyle = '#f6ebd3';
120
- for (const [x, y, radius] of [[35, 45, 2], [53, 67, 1.5], [159, 83, 2], [127, 36, 1.5], [171, 118, 1.5]]) {
121
- context.beginPath();
122
- context.arc(x, y, radius, 0, Math.PI * 2);
123
- context.fill();
124
- }
125
- return canvas;
126
  }
127
-
128
  function canvasPixels(canvas) {
129
- const context = canvas.getContext('2d', { willReadFrequently: true });
130
- const rgba = context.getImageData(0, 0, SIZE, SIZE).data;
131
- const pixels = new Float32Array(PIXEL_COUNT);
132
- for (let pixelIndex = 0; pixelIndex < SIZE * SIZE; pixelIndex += 1) {
133
- for (let channel = 0; channel < 3; channel += 1) {
134
- pixels[pixelIndex * 3 + channel] = rgba[pixelIndex * 4 + channel] / 127.5 - 1;
135
- }
136
- }
137
  return pixels;
138
  }
139
-
140
  function pixelsToCanvas(pixels, canvas) {
141
- const context = canvas.getContext('2d');
142
- const image = context.createImageData(SIZE, SIZE);
143
- for (let index = 0; index < PIXEL_COUNT; index += 1) {
144
- image.data[Math.floor(index / 3) * 4 + index % 3] = Math.round((Math.max(-1, Math.min(1, pixels[index])) + 1) * 127.5);
145
- }
146
- for (let index = 3; index < image.data.length; index += 4) image.data[index] = 255;
147
- context.putImageData(image, 0, 0);
148
  }
149
-
150
- function computeTrajectories() {
151
- const random = randomNormal(82341);
152
- forwardNoise = new Float32Array(PIXEL_COUNT);
153
- for (let index = 0; index < PIXEL_COUNT; index += 1) forwardNoise[index] = random();
154
-
155
- forwardStates = CHECKPOINTS.map((step) => {
156
- if (step === 0) return new Float32Array(sourcePixels);
157
- const alphaBar = schedule.alphaBars[step - 1];
158
- const signalScale = Math.sqrt(alphaBar);
159
- const noiseScale = Math.sqrt(1 - alphaBar);
160
- const state = new Float32Array(PIXEL_COUNT);
161
- for (let index = 0; index < PIXEL_COUNT; index += 1) {
162
- state[index] = signalScale * sourcePixels[index] + noiseScale * forwardNoise[index];
163
- }
164
- return state;
165
- });
166
-
167
- const reverseRandom = randomNormal(9713);
168
- reverseStates = new Array(CHECKPOINTS.length);
169
- let current = new Float32Array(forwardStates[forwardStates.length - 1]);
170
- reverseStates[CHECKPOINTS.length - 1] = new Float32Array(current);
171
-
172
- for (let checkpoint = CHECKPOINTS.length - 2; checkpoint >= 0; checkpoint -= 1) {
173
- const highStep = CHECKPOINTS[checkpoint + 1];
174
- const lowStep = CHECKPOINTS[checkpoint];
175
- const alphaBar = schedule.alphaBars[highStep - 1];
176
- const alphaBarPrevious = lowStep === 0 ? 1 : schedule.alphaBars[lowStep - 1];
177
- const effectiveAlpha = alphaBar / alphaBarPrevious;
178
- const effectiveBeta = 1 - effectiveAlpha;
179
- const denominator = 1 - alphaBar;
180
- const originalWeight = Math.sqrt(alphaBarPrevious) * effectiveBeta / denominator;
181
- const currentWeight = Math.sqrt(effectiveAlpha) * (1 - alphaBarPrevious) / denominator;
182
- const variance = effectiveBeta * (1 - alphaBarPrevious) / denominator;
183
- const noiseScale = Math.sqrt(variance);
184
- const previous = new Float32Array(PIXEL_COUNT);
185
- for (let index = 0; index < PIXEL_COUNT; index += 1) {
186
- previous[index] = originalWeight * sourcePixels[index] + currentWeight * current[index] + noiseScale * reverseRandom();
187
- }
188
- current = previous;
189
- reverseStates[checkpoint] = new Float32Array(current);
190
  }
 
 
 
 
 
 
 
 
 
 
 
191
  }
192
-
193
- function renderCheckpointStrip() {
194
- elements.checkpointStrip.replaceChildren();
195
- CHECKPOINTS.forEach((step, index) => {
196
- const button = document.createElement('button');
197
- button.type = 'button';
198
- button.className = 'checkpoint';
199
- button.dataset.index = String(index);
200
- button.setAttribute('aria-label', `Show checkpoint at step ${step}`);
201
- const canvas = document.createElement('canvas');
202
- canvas.width = SIZE;
203
- canvas.height = SIZE;
204
- const label = document.createElement('span');
205
- label.textContent = step === 0 ? 't = 0' : `t = ${step}`;
206
- button.append(canvas, label);
207
- button.addEventListener('click', () => selectCheckpoint(index));
208
- elements.checkpointStrip.append(button);
209
- });
210
  }
211
-
212
- function render() {
213
- const step = CHECKPOINTS[activeCheckpoint];
214
- const primaryStates = activeMode === 'forward' ? forwardStates : reverseStates;
215
- const activeState = primaryStates[activeCheckpoint];
216
- const outputState = reverseStates[activeCheckpoint];
217
- pixelsToCanvas(sourcePixels, elements.sourceCanvas);
218
- pixelsToCanvas(activeState, elements.stateCanvas);
219
- pixelsToCanvas(outputState, elements.outputCanvas);
220
-
221
- elements.sourceName.textContent = imageLabel;
222
- elements.stateKicker.textContent = activeMode === 'forward' ? '02 / ADDING NOISE' : '02 / REMOVING NOISE';
223
- elements.stateCorner.textContent = `t = ${step}`;
224
- elements.stateTitle.textContent = step === 0 ? 'Original image' : activeMode === 'forward' ? 'Noisy image' : 'Denoised estimate';
225
- elements.stateSubtitle.textContent = step === 0 ? 'No noise added yet' : activeMode === 'forward' ? `${Math.round((1 - schedule.alphaBars[step - 1]) * 100)}% noise variance` : 'Oracle x₀ prediction';
226
- elements.outputTitle.textContent = step === 0 ? 'Original image' : 'Reverse estimate';
227
- elements.outputSubtitle.textContent = step === 0 ? 'Reference image' : `Reverse chain at t = ${step}`;
228
- elements.outputStamp.textContent = step === 0 ? 'x₀' : `${Math.round((1 - schedule.alphaBars[step - 1]) * 100)}% NOISE`;
229
- elements.stepReadout.innerHTML = `STEP ${step} <b>/ ${TOTAL_STEPS}</b>`;
230
- elements.stepSlider.value = String(activeCheckpoint);
231
- elements.scrubberLeft.textContent = activeMode === 'forward' ? 'CLEAN IMAGE' : 'START FROM NOISE';
232
- elements.scrubberRight.textContent = activeMode === 'forward' ? 'PURE NOISE' : 'RECOVER SIGNAL';
233
- elements.explanationTitle.textContent = activeMode === 'forward' ? 'Forward process' : 'Reverse process';
234
- elements.explanationCopy.textContent = activeMode === 'forward'
235
- ? 'At each step, a little Gaussian noise is mixed into the image. The schedule controls how quickly the original signal disappears; jump between checkpoints to see the structure dissolve.'
236
- : 'Each reverse checkpoint samples the DDPM posterior between displayed timesteps. This educational run knows the original image and uses it as an oracle x₀ estimate; a trained model would estimate that signal from the noisy state.';
237
- elements.checkpointStrip.querySelectorAll('.checkpoint').forEach((button, index) => {
238
- button.classList.toggle('active', index === activeCheckpoint);
239
- const thumbState = activeMode === 'forward' ? forwardStates[index] : reverseStates[index];
240
- pixelsToCanvas(thumbState, button.querySelector('canvas'));
241
- });
242
  }
243
-
244
- function selectCheckpoint(index) {
245
- activeCheckpoint = Math.max(0, Math.min(CHECKPOINTS.length - 1, index));
246
- render();
 
 
247
  }
248
-
249
- function setMode(mode) {
250
- activeMode = mode;
251
- document.querySelectorAll('.segment').forEach((button) => {
252
- const selected = button.dataset.mode === mode;
253
- button.classList.toggle('active', selected);
254
- button.setAttribute('aria-pressed', String(selected));
255
- });
256
- render();
 
 
257
  }
258
-
259
- function setPlaying(playing) {
260
- if (animationTimer) window.clearInterval(animationTimer);
261
- animationTimer = null;
262
- elements.playButton.querySelector('.play-icon').classList.toggle('hidden', playing);
263
- elements.playButton.querySelector('.pause-icon').classList.toggle('hidden', !playing);
264
- elements.playButton.setAttribute('aria-label', playing ? 'Pause animation' : 'Play animation');
265
- elements.playButton.title = playing ? 'Pause animation' : 'Play animation';
266
- if (playing) {
267
- animationTimer = window.setInterval(() => {
268
- const next = activeCheckpoint + (activeMode === 'forward' ? 1 : -1);
269
- if (next < 0 || next >= CHECKPOINTS.length) {
270
- setPlaying(false);
271
- return;
272
- }
273
- selectCheckpoint(next);
274
- }, 850);
275
- }
276
  }
277
-
278
- function loadImage(image, label) {
279
- const context = elements.sourceCanvas.getContext('2d');
280
- context.clearRect(0, 0, SIZE, SIZE);
281
- const scale = Math.max(SIZE / image.width, SIZE / image.height);
282
- const width = image.width * scale;
283
- const height = image.height * scale;
284
- context.drawImage(image, (SIZE - width) / 2, (SIZE - height) / 2, width, height);
285
- sourcePixels = canvasPixels(elements.sourceCanvas);
286
- imageLabel = label;
287
- activeCheckpoint = 0;
288
- computeTrajectories();
289
- renderCheckpointStrip();
290
- render();
291
  }
292
-
293
- document.querySelectorAll('.segment').forEach((button) => button.addEventListener('click', () => {
294
- setPlaying(false);
295
- setMode(button.dataset.mode);
296
- }));
297
- elements.playButton.addEventListener('click', () => setPlaying(!animationTimer));
298
- elements.stepSlider.addEventListener('input', (event) => {
299
- setPlaying(false);
300
- selectCheckpoint(Number(event.target.value));
301
- });
302
- document.querySelector('#step-back').addEventListener('click', () => {
303
- setPlaying(false);
304
- selectCheckpoint(activeCheckpoint - 1);
305
- });
306
- document.querySelector('#step-forward').addEventListener('click', () => {
307
- setPlaying(false);
308
- selectCheckpoint(activeCheckpoint + 1);
309
- });
310
- elements.imageUpload.addEventListener('change', (event) => {
311
- const file = event.target.files?.[0];
312
- if (!file) return;
313
- const image = new Image();
314
- image.onload = () => {
315
- loadImage(image, file.name.replace(/\.[^.]+$/, ''));
316
- URL.revokeObjectURL(image.src);
317
- };
318
- image.src = URL.createObjectURL(file);
319
- });
320
-
321
- const starterImage = makeSourceArt();
322
- sourcePixels = canvasPixels(starterImage);
323
- computeTrajectories();
324
- renderCheckpointStrip();
325
- render();
 
1
  const TOTAL_STEPS = 1000;
2
+ const MODEL_NAME = 'google/ddpm-celebahq-256';
3
+ const DEFAULT_CHECKPOINTS = [0, 50, 100, 200, 350, 500, 700, 1000];
4
  const SIZE = 192;
5
  const PIXEL_COUNT = SIZE * SIZE * 3;
 
6
  const elements = {
7
+ timestepsInput: document.querySelector('#timesteps-input'), applyTimesteps: document.querySelector('#apply-timesteps'),
8
+ seedInput: document.querySelector('#seed-input'), randomizeSeed: document.querySelector('#randomize-seed'), configError: document.querySelector('#config-error'), diffusionTrack: document.querySelector('#diffusion-track'),
9
+ denoisingTrack: document.querySelector('#denoising-track'), imageUpload: document.querySelector('#image-upload'), modelStatus: document.querySelector('#model-status'),
10
+ forwardProgressLabel: document.querySelector('#forward-progress-label'), reverseProgressLabel: document.querySelector('#reverse-progress-label'),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  };
12
+ let checkpoints = [...DEFAULT_CHECKPOINTS];
13
  let sourcePixels = new Float32Array(PIXEL_COUNT);
 
14
  let forwardStates = [];
15
+ let reverseStates = new Map();
 
 
 
16
  let imageLabel = 'Still life 01';
17
+ let forwardRevealCount = 1;
18
+ let forwardCompleted = false;
19
+ let reverseNoiseState = null;
20
+ let reverseInputMode = 'seed';
21
+ let animationTimer = null;
22
+ let generationController = null;
23
+ let generationBusy = false;
24
+ const progressStartedAt = { forward: null, reverse: null };
25
  const schedule = makeSchedule();
26
 
27
  function makeSchedule() {
28
+ const alphaBars = new Float64Array(TOTAL_STEPS); let product = 1;
29
+ for (let step = 0; step < TOTAL_STEPS; step += 1) { const beta = .0001 + (.02 - .0001) * step / (TOTAL_STEPS - 1); product *= 1 - beta; alphaBars[step] = product; }
30
+ return alphaBars;
 
 
 
 
 
 
 
 
31
  }
 
32
  function randomNormal(seed) {
33
  let state = seed >>> 0;
34
+ return () => { state = (state + 0x6D2B79F5) | 0; let value = state; value = Math.imul(value ^ value >>> 15, value | 1); value ^= value + Math.imul(value ^ value >>> 7, value | 61); const uniform = ((value ^ value >>> 14) >>> 0) / 4294967296; return Math.sqrt(-2 * Math.log(Math.max(uniform, 1e-7))) * Math.cos(2 * Math.PI * uniform); };
 
 
 
 
 
 
 
35
  }
 
36
  function makeSourceArt() {
37
+ const canvas = document.createElement('canvas'); canvas.width = SIZE; canvas.height = SIZE; const context = canvas.getContext('2d');
38
+ const sky = context.createLinearGradient(0, 0, SIZE, SIZE); sky.addColorStop(0, '#dce7db'); sky.addColorStop(.58, '#b8c7ad'); sky.addColorStop(1, '#a2b5a0'); context.fillStyle = sky; context.fillRect(0, 0, SIZE, SIZE);
39
+ context.fillStyle = '#e9d6b5'; context.beginPath(); context.arc(147, 44, 24, 0, Math.PI * 2); context.fill();
40
+ context.fillStyle = '#52765f'; context.beginPath(); context.moveTo(0, 137); context.quadraticCurveTo(50, 91, 99, 137); context.quadraticCurveTo(145, 109, 192, 132); context.lineTo(192, 192); context.lineTo(0, 192); context.fill();
41
+ context.fillStyle = '#315b48'; context.beginPath(); context.moveTo(0, 159); context.quadraticCurveTo(61, 127, 112, 164); context.quadraticCurveTo(155, 143, 192, 157); context.lineTo(192, 192); context.lineTo(0, 192); context.fill();
42
+ context.fillStyle = '#d8795d'; context.beginPath(); context.ellipse(92, 144, 24, 31, -.22, 0, Math.PI * 2); context.fill(); context.fillStyle = '#e8a37c'; context.beginPath(); context.ellipse(84, 136, 14, 21, -.22, 0, Math.PI * 2); context.fill();
43
+ context.fillStyle = '#496c52'; context.beginPath(); context.ellipse(103, 111, 21, 10, -.62, 0, Math.PI * 2); context.fill(); context.beginPath(); context.ellipse(77, 109, 16, 8, .52, 0, Math.PI * 2); context.fill(); return canvas;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  }
 
45
  function canvasPixels(canvas) {
46
+ const rgba = canvas.getContext('2d', { willReadFrequently: true }).getImageData(0, 0, SIZE, SIZE).data; const pixels = new Float32Array(PIXEL_COUNT);
47
+ for (let pixel = 0; pixel < SIZE * SIZE; pixel += 1) for (let channel = 0; channel < 3; channel += 1) pixels[pixel * 3 + channel] = rgba[pixel * 4 + channel] / 127.5 - 1;
 
 
 
 
 
 
48
  return pixels;
49
  }
 
50
  function pixelsToCanvas(pixels, canvas) {
51
+ const context = canvas.getContext('2d'); const image = context.createImageData(SIZE, SIZE);
52
+ for (let index = 0; index < PIXEL_COUNT; index += 1) image.data[Math.floor(index / 3) * 4 + index % 3] = Math.round((Math.max(-1, Math.min(1, pixels[index])) + 1) * 127.5);
53
+ for (let index = 3; index < image.data.length; index += 4) image.data[index] = 255; context.putImageData(image, 0, 0);
 
 
 
 
54
  }
55
+ function drawState(canvas, state) { const context = canvas.getContext('2d'); context.clearRect(0, 0, SIZE, SIZE); if (state instanceof HTMLCanvasElement) context.drawImage(state, 0, 0, SIZE, SIZE); else if (state instanceof Float32Array) pixelsToCanvas(state, canvas); }
56
+ function computeForwardStates() {
57
+ const random = randomNormal(readSeed()); const noise = new Float32Array(PIXEL_COUNT); for (let index = 0; index < PIXEL_COUNT; index += 1) noise[index] = random();
58
+ forwardStates = checkpoints.map((step) => { if (step === 0) return new Float32Array(sourcePixels); const alphaBar = schedule[step - 1]; const state = new Float32Array(PIXEL_COUNT); for (let index = 0; index < PIXEL_COUNT; index += 1) state[index] = Math.sqrt(alphaBar) * sourcePixels[index] + Math.sqrt(1 - alphaBar) * noise[index]; return state; });
59
+ }
60
+ function makeNoiseState(seed) { const random = randomNormal(seed); const state = new Float32Array(PIXEL_COUNT); for (let index = 0; index < PIXEL_COUNT; index += 1) state[index] = random(); return state; }
61
+ function stateToPng(state) { const canvas = document.createElement('canvas'); canvas.width = 256; canvas.height = 256; const small = document.createElement('canvas'); small.width = SIZE; small.height = SIZE; drawState(small, state); canvas.getContext('2d').drawImage(small, 0, 0, 256, 256); return canvas.toDataURL('image/png'); }
62
+ function setModelStatus(message) { if (elements.modelStatus) elements.modelStatus.textContent = message; }
63
+ function resampleNoise() {
64
+ const values = new Uint32Array(1); crypto.getRandomValues(values); const seed = values[0] & 0x7fffffff; elements.seedInput.value = String(seed); reverseInputMode = 'seed'; reverseNoiseState = makeNoiseState(seed); reverseStates = new Map(); resetProgress('reverse'); setModelStatus(`Resampled Gaussian noise · seed ${seed}.`); renderTracks();
65
+ }
66
+ function useForwardNoise() {
67
+ if (!forwardCompleted) return; reverseInputMode = 'forward'; reverseNoiseState = new Float32Array(forwardStates.at(-1)); reverseStates = new Map(); resetProgress('reverse'); setModelStatus('Using the final forward-process noise as the reverse input.'); renderTracks();
68
+ }
69
+ function makeActionButton(label, id, action) { const button = document.createElement('button'); button.type = 'button'; button.id = id; button.className = 'process-button'; button.textContent = label; button.addEventListener('click', action); return button; }
70
+ function makeCard(step, state, type, index) {
71
+ const card = document.createElement('article'); card.className = 'stage-card'; card.dataset.step = String(step); const actionSlot = document.createElement('div'); actionSlot.className = 'action-slot';
72
+ if (type === 'forward' && index >= forwardRevealCount) card.classList.add('is-pending');
73
+ if (type === 'forward' && index === 0) actionSlot.append(makeActionButton('Diffuse', 'diffuse-button', runDiffusion));
74
+ if (type === 'forward' && index === checkpoints.length - 1) { actionSlot.classList.add('status-right'); actionSlot.append(elements.forwardProgressLabel); }
75
+ if (type === 'reverse' && index === 0) actionSlot.append(elements.reverseProgressLabel);
76
+ if (type === 'reverse' && index === checkpoints.length - 1) {
77
+ actionSlot.classList.add('reverse-actions');
78
+ actionSlot.append(makeActionButton('Denoise', 'denoise-button', generateReverse));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  }
80
+ const canvasWrap = document.createElement('div'); canvasWrap.className = 'stage-canvas'; const canvas = document.createElement('canvas'); canvas.width = SIZE; canvas.height = SIZE; drawState(canvas, state); canvasWrap.append(canvas);
81
+ if (type === 'forward' && index === 0) { const change = document.createElement('button'); change.className = 'change-image'; change.type = 'button'; change.textContent = 'Change image'; change.addEventListener('click', () => elements.imageUpload.click()); canvasWrap.append(change); }
82
+ if (type === 'reverse' && index === checkpoints.length - 1) { const noiseActions = document.createElement('div'); noiseActions.className = 'noise-actions'; noiseActions.append(makeActionButton('Resample', 'resample-button', resampleNoise), makeActionButton('Forward noise', 'forward-noise-button', useForwardNoise)); canvasWrap.append(noiseActions); }
83
+ const caption = document.createElement('div'); caption.className = 'stage-caption';
84
+ const titleText = type === 'forward' && index === 0 ? imageLabel : step === 0 ? 'Generated face' : step === TOTAL_STEPS ? 'Gaussian noise' : '';
85
+ if (titleText) { const title = document.createElement('strong'); title.textContent = titleText; caption.append(title); }
86
+ const time = document.createElement('span'); time.textContent = `t = ${step}`; caption.append(time);
87
+ const preview = document.createElement('div'); preview.className = 'hover-preview'; const previewCanvas = document.createElement('canvas'); previewCanvas.width = 256; previewCanvas.height = 256; if (state) { const context = previewCanvas.getContext('2d'); context.imageSmoothingEnabled = true; if (state instanceof HTMLCanvasElement) context.drawImage(state, 0, 0, 256, 256); else { const temporary = document.createElement('canvas'); temporary.width = SIZE; temporary.height = SIZE; drawState(temporary, state); context.drawImage(temporary, 0, 0, 256, 256); } } preview.append(previewCanvas);
88
+ canvas.addEventListener('mouseenter', () => { const rect = canvasWrap.getBoundingClientRect(); preview.style.left = `${Math.max(8, Math.min(window.innerWidth - 264, rect.left + rect.width / 2 - 128))}px`; preview.style.top = `${Math.max(8, Math.min(window.innerHeight - 264, rect.top + rect.height / 2 - 128))}px`; preview.classList.add('is-visible'); });
89
+ canvas.addEventListener('mouseleave', () => preview.classList.remove('is-visible'));
90
+ card.append(actionSlot, canvasWrap, caption, preview); return card;
91
  }
92
+ function renderTracks() {
93
+ elements.diffusionTrack.style.setProperty('--stage-count', checkpoints.length);
94
+ elements.denoisingTrack.style.setProperty('--stage-count', checkpoints.length);
95
+ elements.diffusionTrack.replaceChildren(...checkpoints.map((step, index) => makeCard(step, forwardStates[index], 'forward', index)));
96
+ elements.denoisingTrack.replaceChildren(...checkpoints.map((step, index) => makeCard(step, reverseStates.get(step) || (step === TOTAL_STEPS ? reverseNoiseState : null), 'reverse', index)));
97
+ const button = document.querySelector('#denoise-button'); if (button) { button.disabled = generationBusy; button.textContent = generationBusy ? 'Denoising…' : 'Denoise'; }
98
+ const forwardNoiseButton = document.querySelector('#forward-noise-button'); if (forwardNoiseButton) { forwardNoiseButton.disabled = generationBusy || !forwardCompleted; forwardNoiseButton.title = forwardCompleted ? 'Use the last forward-process image' : 'Run Diffuse first'; }
99
+ const resampleButton = document.querySelector('#resample-button'); if (resampleButton) resampleButton.disabled = generationBusy;
 
 
 
 
 
 
 
 
 
 
100
  }
101
+ function formatDuration(seconds) {
102
+ if (!Number.isFinite(seconds)) return '?';
103
+ const whole = Math.max(0, Math.round(seconds)); const hours = Math.floor(whole / 3600); const minutes = Math.floor(whole % 3600 / 60); const remainder = whole % 60;
104
+ return hours ? `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(remainder).padStart(2, '0')}` : `${String(minutes).padStart(2, '0')}:${String(remainder).padStart(2, '0')}`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  }
106
+ function resetProgress(kind) { progressStartedAt[kind] = null; setProgress(kind, 0); }
107
+ function beginProgress(kind) { progressStartedAt[kind] = performance.now(); setProgress(kind, 0); }
108
+ function setProgress(kind, completedSteps) {
109
+ const completed = Math.max(0, Math.min(TOTAL_STEPS, Math.round(completedSteps))); const label = kind === 'forward' ? elements.forwardProgressLabel : elements.reverseProgressLabel;
110
+ const elapsed = progressStartedAt[kind] === null ? 0 : (performance.now() - progressStartedAt[kind]) / 1000; const rate = elapsed > 0 && completed > 0 ? completed / elapsed : 0; const remaining = rate > 0 ? (TOTAL_STEPS - completed) / rate : Infinity;
111
+ const text = `${completed}/${TOTAL_STEPS} [${formatDuration(elapsed)}<${completed === TOTAL_STEPS ? '00:00' : formatDuration(remaining)}, ${rate > 0 ? rate.toFixed(2) : '?'}it/s]`; label.value = text; label.textContent = text;
112
  }
113
+ function stopAnimation() { if (animationTimer) window.clearInterval(animationTimer); animationTimer = null; document.querySelectorAll('.stage-card.is-active').forEach((card) => card.classList.remove('is-active')); }
114
+ function runDiffusion() {
115
+ try { computeForwardStates(); elements.configError.textContent = ''; } catch (error) { elements.configError.textContent = error.message; return; }
116
+ stopAnimation(); forwardCompleted = false; forwardRevealCount = 1; beginProgress('forward'); renderTracks(); const cards = [...elements.diffusionTrack.querySelectorAll('.stage-card')]; cards[0].classList.add('is-active');
117
+ const duration = Math.max(1000, (checkpoints.length - 1) * 650); const startedAt = performance.now(); let activeIndex = 0;
118
+ animationTimer = window.setInterval(() => {
119
+ const completed = Math.min(TOTAL_STEPS, Math.floor((performance.now() - startedAt) / duration * TOTAL_STEPS)); setProgress('forward', completed);
120
+ let nextIndex = activeIndex; while (nextIndex + 1 < checkpoints.length && checkpoints[nextIndex + 1] <= completed) nextIndex += 1;
121
+ if (nextIndex !== activeIndex) { cards[activeIndex].classList.remove('is-active'); for (let index = activeIndex + 1; index <= nextIndex; index += 1) cards[index].classList.remove('is-pending'); cards[nextIndex].classList.add('is-active'); activeIndex = nextIndex; forwardRevealCount = nextIndex + 1; }
122
+ if (completed >= TOTAL_STEPS) { forwardCompleted = true; setProgress('forward', TOTAL_STEPS); stopAnimation(); renderTracks(); }
123
+ }, 50);
124
  }
125
+ function parseTimesteps() {
126
+ const values = elements.timestepsInput.value.split(',').map((part) => part.trim()).filter(Boolean).map(Number);
127
+ if (values.length < 2 || values.some((value) => !Number.isInteger(value) || value < 0 || value > TOTAL_STEPS)) throw new Error('Enter at least two whole numbers between 0 and 1000.');
128
+ const unique = [...new Set(values)].sort((a, b) => a - b); if (unique[0] !== 0 || unique.at(-1) !== TOTAL_STEPS) throw new Error('Include both 0 and 1000 so each process has a start and end.'); if (unique.length > 12) throw new Error('Use no more than 12 timesteps.'); return unique;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
  }
130
+ function readSeed() {
131
+ const value = Number(elements.seedInput.value.trim());
132
+ if (!Number.isSafeInteger(value) || value < 0 || value > 2147483647) throw new Error('Seed must be a whole number from 0 to 2147483647.');
133
+ return value;
 
 
 
 
 
 
 
 
 
 
134
  }
135
+ function applyTimesteps() {
136
+ try { const next = parseTimesteps(); stopAnimation(); if (generationController) generationController.abort(); checkpoints = next; reverseStates = new Map(); reverseInputMode = 'seed'; reverseNoiseState = makeNoiseState(readSeed()); forwardRevealCount = 1; forwardCompleted = false; computeForwardStates(); resetProgress('forward'); resetProgress('reverse'); elements.timestepsInput.value = checkpoints.join(', '); elements.configError.textContent = ''; setModelStatus('Timesteps updated. Select Denoise to run the model.'); renderTracks(); }
137
+ catch (error) { elements.configError.textContent = error.message; }
138
+ }
139
+ function decodeFrame(data) { return new Promise((resolve, reject) => { const image = new Image(); image.onload = () => { const canvas = document.createElement('canvas'); canvas.width = image.naturalWidth; canvas.height = image.naturalHeight; canvas.getContext('2d').drawImage(image, 0, 0); resolve(canvas); }; image.onerror = () => reject(new Error('The backend returned an unreadable image frame.')); image.src = `data:image/jpeg;base64,${data}`; }); }
140
+ async function generateReverse() {
141
+ if (generationBusy) return;
142
+ let seed;
143
+ try { seed = readSeed(); elements.configError.textContent = ''; } catch (error) { elements.configError.textContent = error.message; return; }
144
+ stopAnimation(); generationController = new AbortController(); generationBusy = true; reverseStates = new Map(); resetProgress('reverse'); setModelStatus('Loading model weights if needed, then sampling 1,000 reverse steps…'); renderTracks();
145
+ try {
146
+ const initialImage = reverseInputMode === 'forward' ? stateToPng(reverseNoiseState) : null;
147
+ const response = await fetch('/api/generate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ checkpoints, seed, initial_image: initialImage }), signal: generationController.signal }); if (!response.ok || !response.body) throw new Error(`Backend request failed (${response.status}).`);
148
+ const reader = response.body.getReader(); const decoder = new TextDecoder(); let pending = '';
149
+ while (true) { const { value, done } = await reader.read(); pending += decoder.decode(value || new Uint8Array(), { stream: !done }); const lines = pending.split('\n'); pending = lines.pop(); for (const line of lines) if (line.trim()) await handleModelEvent(JSON.parse(line)); if (done) break; }
150
+ if (pending.trim()) await handleModelEvent(JSON.parse(pending));
151
+ } catch (error) { if (error.name !== 'AbortError') setModelStatus(`Model error: ${error.message}`); }
152
+ finally { generationBusy = false; generationController = null; renderTracks(); }
153
+ }
154
+ async function handleModelEvent(event) {
155
+ if (event.type === 'status') setModelStatus(`Loading ${event.model} on the first generation…`);
156
+ if (event.type === 'progress') { const completed = event.total - event.step; if (progressStartedAt.reverse === null) beginProgress('reverse'); setProgress('reverse', completed); setModelStatus(`Denoising · ${completed}/${event.total} steps complete.`); }
157
+ if (event.type === 'frame') { reverseStates.set(event.step, await decodeFrame(event.image)); setProgress('reverse', TOTAL_STEPS - event.step); setModelStatus(`Real model inference · received t = ${event.step}.`); renderTracks(); const card = elements.denoisingTrack.querySelector(`[data-step="${event.step}"]`); if (card) { document.querySelectorAll('#denoising-track .is-active').forEach((item) => item.classList.remove('is-active')); card.classList.add('is-active'); } }
158
+ if (event.type === 'complete') { setProgress('reverse', TOTAL_STEPS); setModelStatus(`Generated with ${MODEL_NAME} on ${event.device.toUpperCase()} · seed ${event.seed}.`); } if (event.type === 'error') throw new Error(event.message);
159
+ }
160
+ function loadImage(image, label) { const canvas = document.createElement('canvas'); canvas.width = SIZE; canvas.height = SIZE; const context = canvas.getContext('2d'); const scale = Math.max(SIZE / image.width, SIZE / image.height); const width = image.width * scale; const height = image.height * scale; context.drawImage(image, (SIZE - width) / 2, (SIZE - height) / 2, width, height); sourcePixels = canvasPixels(canvas); imageLabel = label; reverseInputMode = 'seed'; reverseNoiseState = makeNoiseState(readSeed()); forwardRevealCount = 1; forwardCompleted = false; resetProgress('forward'); computeForwardStates(); renderTracks(); }
161
+ elements.applyTimesteps.addEventListener('click', applyTimesteps); elements.timestepsInput.addEventListener('keydown', (event) => { if (event.key === 'Enter') applyTimesteps(); });
162
+ elements.randomizeSeed.addEventListener('click', () => { resampleNoise(); elements.configError.textContent = ''; forwardRevealCount = 1; forwardCompleted = false; resetProgress('forward'); computeForwardStates(); renderTracks(); });
163
+ elements.imageUpload.addEventListener('change', (event) => { const file = event.target.files?.[0]; if (!file) return; const image = new Image(); image.onload = () => { loadImage(image, file.name.replace(/\.[^.]+$/, '')); URL.revokeObjectURL(image.src); }; image.src = URL.createObjectURL(file); });
164
+ sourcePixels = canvasPixels(makeSourceArt()); computeForwardStates(); reverseNoiseState = makeNoiseState(readSeed()); resetProgress('forward'); resetProgress('reverse'); renderTracks();
 
 
 
 
generate.py ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import argparse
2
+ import secrets
3
+ import threading
4
+ from pathlib import Path
5
+
6
+ from PIL import Image
7
+
8
+
9
+ ROOT = Path(__file__).resolve().parent
10
+ MODEL_ID = "google/ddpm-celebahq-256"
11
+ CHECKPOINTS = (0, 50, 100, 200, 350, 500, 700, 1000)
12
+
13
+ _MODEL_LOCK = threading.Lock()
14
+ _PIPELINE = None
15
+ _TORCH = None
16
+ _DEVICE = None
17
+
18
+
19
+ def load_model():
20
+ """Load and cache the diffusion pipeline, returning it and its device."""
21
+ global _PIPELINE, _TORCH, _DEVICE
22
+
23
+ if _PIPELINE is None:
24
+ try:
25
+ import torch
26
+ from diffusers import DDPMPipeline
27
+ except ImportError as error:
28
+ raise RuntimeError(
29
+ "Diffusers dependencies are missing from this Python environment. "
30
+ "Install them with `python -m pip install -r requirements.txt`. "
31
+ f"Import detail: {error}"
32
+ ) from error
33
+
34
+ if torch.cuda.is_available():
35
+ device = "cuda"
36
+ elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
37
+ device = "mps"
38
+ else:
39
+ device = "cpu"
40
+
41
+ print(f"Using device: {device}")
42
+ pipeline = DDPMPipeline.from_pretrained(MODEL_ID, cache_dir=ROOT / "models")
43
+ pipeline.to(device)
44
+ _TORCH = torch
45
+ _DEVICE = device
46
+ _PIPELINE = pipeline
47
+
48
+ return _PIPELINE, _TORCH, _DEVICE
49
+
50
+
51
+ def is_model_loaded():
52
+ return _PIPELINE is not None
53
+
54
+
55
+ def sample_to_image(sample):
56
+ """Convert a model sample tensor into a PIL image."""
57
+ image = sample[0].detach().float().cpu().clamp(-1, 1)
58
+ image = ((image + 1) * 127.5).round().byte().permute(1, 2, 0).numpy()
59
+ return Image.fromarray(image, mode="RGB")
60
+
61
+
62
+ def generate_images(seed, checkpoints=CHECKPOINTS, initial_image=None):
63
+ """Yield every remaining-step count and an image at requested checkpoints."""
64
+ with _MODEL_LOCK:
65
+ pipeline, torch, device = load_model()
66
+ scheduler = pipeline.scheduler
67
+ training_steps = scheduler.config.num_train_timesteps
68
+ scheduler.set_timesteps(training_steps, device=device)
69
+ generator = torch.Generator(device=device).manual_seed(seed)
70
+ unet = pipeline.unet
71
+ sample_size = unet.config.sample_size
72
+ if isinstance(sample_size, (tuple, list)):
73
+ height, width = sample_size
74
+ else:
75
+ height = width = sample_size
76
+ if initial_image is None:
77
+ sample = torch.randn(
78
+ (1, unet.config.in_channels, height, width),
79
+ generator=generator,
80
+ device=device,
81
+ dtype=unet.dtype,
82
+ ) * scheduler.init_noise_sigma
83
+ else:
84
+ import numpy as np
85
+
86
+ pixels = np.asarray(initial_image.convert("RGB").resize((width, height))).copy()
87
+ sample = torch.from_numpy(pixels).permute(2, 0, 1).unsqueeze(0)
88
+ sample = sample.to(device=device, dtype=unet.dtype) / 127.5 - 1
89
+
90
+ yield (
91
+ training_steps,
92
+ sample_to_image(sample) if training_steps in checkpoints else None,
93
+ )
94
+
95
+ with torch.inference_mode():
96
+ for index, timestep in enumerate(scheduler.timesteps):
97
+ model_output = unet(sample, timestep).sample
98
+ sample = scheduler.step(
99
+ model_output,
100
+ timestep,
101
+ sample,
102
+ generator=generator,
103
+ ).prev_sample
104
+ remaining_steps = training_steps - index - 1
105
+ image = sample_to_image(sample) if remaining_steps in checkpoints else None
106
+ yield remaining_steps, image
107
+
108
+
109
+ def generate_image(seed):
110
+ """Generate and return the final image for a seed."""
111
+ final_image = None
112
+ for _, image in generate_images(seed, checkpoints=(0,)):
113
+ if image is not None:
114
+ final_image = image
115
+ if final_image is None:
116
+ raise RuntimeError("The diffusion pipeline did not produce an image.")
117
+ return final_image
118
+
119
+
120
+ def main():
121
+ parser = argparse.ArgumentParser(description="Generate a CelebA-HQ face image.")
122
+ parser.add_argument(
123
+ "--seed",
124
+ type=int,
125
+ default=None,
126
+ help="Random seed (defaults to a randomly generated seed)",
127
+ )
128
+ args = parser.parse_args()
129
+ seed = args.seed if args.seed is not None else secrets.randbelow(2**31)
130
+ if seed < 0 or seed >= 2**63:
131
+ parser.error("seed must be between 0 and 2^63 - 1")
132
+
133
+ print(f"Generating with seed: {seed}")
134
+ image = generate_image(seed)
135
+
136
+ import matplotlib.pyplot as plt
137
+
138
+ plt.imshow(image)
139
+ plt.title(f"{MODEL_ID} — seed {seed}")
140
+ plt.axis("off")
141
+ plt.tight_layout()
142
+ plt.show()
143
+
144
+
145
+ if __name__ == "__main__":
146
+ main()
index.html CHANGED
@@ -13,118 +13,53 @@
13
  <body>
14
  <div class="app-shell">
15
  <header class="topbar">
16
- <a class="brand" href="#top" aria-label="Diffusion Lab home">
17
- <span class="brand-mark" aria-hidden="true"><i></i><i></i><i></i><i></i></span>
18
- <span>diffusion<span class="brand-light">lab</span></span>
19
- </a>
20
- <div class="topbar-right">
21
- <span class="live-dot" aria-hidden="true"></span>
22
- <span>INTERACTIVE NOTE 01</span>
23
- <span class="topbar-rule"></span>
24
- <span>DDPM / 2020</span>
25
- </div>
26
  </header>
27
-
28
  <main id="top">
29
- <section class="intro">
30
- <div class="intro-copy">
31
- <p class="eyebrow"><span>01</span> THE GENERATIVE LOOP</p>
32
- <h1>Noise in.<br><span>Signal out.</span></h1>
33
- <p class="dek">A diffusion model learns to undo a little noise at a time. Follow one image through the forward process, then watch the reverse chain find its way back.</p>
34
- </div>
35
- <div class="equation-note" aria-label="Forward diffusion equation">
36
- <span class="equation-label">ONE STEP FORWARD</span>
37
- <span class="equation">x<sub>t</sub> = √α̅<sub>t</sub> x<sub>0</sub> + √(1 − α̅<sub>t</sub>) ε</span>
38
- <span class="equation-foot">signal fades <b>→</b> Gaussian noise grows</span>
39
- </div>
40
- </section>
41
-
42
- <section class="workbench" aria-label="Diffusion process explorer">
43
- <div class="workbench-head">
44
- <div class="section-title">
45
- <span class="section-index">A</span>
46
- <div>
47
- <p class="eyebrow">IMAGE EXPLORER</p>
48
- <h2>One image, two directions</h2>
49
- </div>
50
  </div>
51
- <div class="controls">
52
- <div class="segmented" role="group" aria-label="Process direction">
53
- <button class="segment active" data-mode="forward" aria-pressed="true">Forward</button>
54
- <button class="segment" data-mode="reverse" aria-pressed="false">Reverse</button>
55
- </div>
56
- <button id="play-button" class="icon-button" aria-label="Play animation" title="Play animation">
57
- <svg class="play-icon" viewBox="0 0 20 20" aria-hidden="true"><path d="M6.5 4.7v10.6L15 10z"/></svg>
58
- <svg class="pause-icon hidden" viewBox="0 0 20 20" aria-hidden="true"><path d="M5.5 4.5h3.2v11H5.5zm5.8 0h3.2v11h-3.2z"/></svg>
59
- </button>
60
  </div>
61
- </div>
62
-
63
- <div class="process-track">
64
- <article class="image-panel source-panel">
65
- <div class="panel-topline"><span>01 / SOURCE</span><span class="panel-tag">x<sub>0</sub></span></div>
66
- <div class="canvas-wrap source-wrap"><canvas id="source-canvas" width="192" height="192" aria-label="Source image"></canvas>
67
- <label class="upload-control" for="image-upload" title="Choose an image">
68
- <svg viewBox="0 0 20 20" aria-hidden="true"><path d="M10 13V3m0 0L6.5 6.5M10 3l3.5 3.5M4 12.5v3A1.5 1.5 0 0 0 5.5 17h9a1.5 1.5 0 0 0 1.5-1.5v-3"/></svg>
69
- <span>Change image</span>
70
- </label>
71
- <input id="image-upload" type="file" accept="image/*" hidden>
72
- </div>
73
- <div class="panel-caption"><strong id="source-name">Still life 01</strong><span>your starting signal</span></div>
74
- </article>
75
-
76
- <div class="flow-arrow" aria-hidden="true"><span></span></div>
77
-
78
- <article class="image-panel state-panel">
79
- <div class="panel-topline"><span id="state-kicker">02 / ADDING NOISE</span><span class="panel-tag" id="state-tag">x<sub>t</sub></span></div>
80
- <div class="canvas-wrap state-wrap"><canvas id="state-canvas" width="192" height="192" aria-label="Current diffusion state"></canvas>
81
- <span class="state-corner" id="state-corner">t = 0</span>
82
- </div>
83
- <div class="panel-caption"><strong id="state-title">Original image</strong><span id="state-subtitle">No noise added yet</span></div>
84
- </article>
85
-
86
- <div class="flow-arrow reverse-arrow" aria-hidden="true"><span></span></div>
87
-
88
- <article class="image-panel output-panel">
89
- <div class="panel-topline"><span>03 / RECONSTRUCTED</span><span class="panel-tag">x<sub>0</sub></span></div>
90
- <div class="canvas-wrap output-wrap"><canvas id="output-canvas" width="192" height="192" aria-label="Reverse denoising result"></canvas>
91
- <span class="output-stamp" id="output-stamp">READY</span>
92
- </div>
93
- <div class="panel-caption"><strong id="output-title">Reverse estimate</strong><span id="output-subtitle">Play reverse to begin</span></div>
94
- </article>
95
- </div>
96
-
97
- <div class="scrubber-row">
98
- <button id="step-back" class="step-button" aria-label="Previous checkpoint" title="Previous checkpoint"><svg viewBox="0 0 20 20" aria-hidden="true"><path d="m12.5 4.5-5.5 5.5 5.5 5.5"/></svg></button>
99
- <div class="scrubber-main">
100
- <div class="scrubber-labels"><span id="scrubber-left">CLEAN IMAGE</span><span id="step-readout">STEP 0 <b>/ 1000</b></span><span id="scrubber-right">PURE NOISE</span></div>
101
- <input id="step-slider" type="range" min="0" max="7" value="0" step="1" aria-label="Diffusion checkpoint">
102
- <div class="tick-row" aria-hidden="true"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>
103
  </div>
104
- <button id="step-forward" class="step-button" aria-label="Next checkpoint" title="Next checkpoint"><svg viewBox="0 0 20 20" aria-hidden="true"><path d="m7.5 4.5 5.5 5.5-5.5 5.5"/></svg></button>
105
  </div>
106
-
107
- <div class="checkpoint-strip" id="checkpoint-strip" aria-label="Diffusion checkpoints"></div>
108
  </section>
109
-
110
- <section class="lower-grid">
111
- <div class="process-explainer">
112
- <p class="eyebrow"><span>B</span> WHAT YOU'RE SEEING</p>
113
- <h2 id="explanation-title">Forward process</h2>
114
- <p id="explanation-copy">At each step, a little Gaussian noise is mixed into the image. The schedule controls how quickly the original signal disappears; jump between checkpoints to see the structure dissolve.</p>
115
- </div>
116
- <div class="legend-panel">
117
- <div class="legend-heading"><span>PROCESS KEY</span><span>DDPM / ε-prediction</span></div>
118
- <div class="legend-item"><span class="legend-swatch signal"></span><span><b>Signal</b> — original image information</span></div>
119
- <div class="legend-item"><span class="legend-swatch noise"></span><span><b>Noise</b> — sampled from N(0, I)</span></div>
120
- <div class="legend-item"><span class="legend-swatch estimate"></span><span><b>Estimate</b> — reverse-step reconstruction</span></div>
121
- <p class="model-note">EDUCATIONAL SIMULATION <span>·</span> reverse steps use the reference image as an oracle x<sub>0</sub> estimate. Real DDPMs learn to predict noise with a neural network.</p>
122
  </div>
 
 
123
  </section>
124
  </main>
125
-
126
- <footer><span>DIFFUSION LAB <span class="footer-dot">/</span> A SMALL STUDY IN GENERATIVE MODELS</span><span>β<sub>t</sub> LINEAR <span class="footer-dot">·</span> 1,000 TIMESTEPS</span></footer>
127
  </div>
 
128
  <script src="app.js" defer></script>
129
  </body>
130
  </html>
 
13
  <body>
14
  <div class="app-shell">
15
  <header class="topbar">
16
+ <a class="brand" href="#top"><span class="brand-mark" aria-hidden="true"><i></i><i></i><i></i><i></i></span><span>diffusion<span class="brand-light">lab</span></span></a>
17
+ <div class="topbar-right"><span class="live-dot"></span></div>
 
 
 
 
 
 
 
 
18
  </header>
 
19
  <main id="top">
20
+ <section class="control-panel lab-panel" aria-labelledby="controls-title">
21
+ <h1 id="controls-title">Configure</h1>
22
+ <div class="config-rows">
23
+ <div class="config-row">
24
+ <label for="model-input">Model</label>
25
+ <input id="model-input" type="text" value="google/ddpm-celebahq-256" readonly>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  </div>
27
+ <div class="config-row">
28
+ <label for="timesteps-input">Timesteps</label>
29
+ <input id="timesteps-input" type="text" value="0, 50, 100, 200, 350, 500, 700, 1000" spellcheck="false" aria-describedby="timesteps-help config-error">
30
+ <button id="apply-timesteps" type="button">Apply</button>
 
 
 
 
 
31
  </div>
32
+ <div class="config-row">
33
+ <button id="randomize-seed" class="row-label-button" type="button" title="Generate a new random seed">Seed</button>
34
+ <input id="seed-input" type="text" inputmode="numeric" value="42" spellcheck="false" aria-describedby="seed-help config-error">
35
+ </div>
36
+ <div class="config-help">
37
+ <span id="timesteps-help">Timesteps: comma-separated values from 0 to 1000.</span>
38
+ <span id="seed-help">Select Seed for a new random value.</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  </div>
40
+ <p id="config-error" class="field-error" role="alert"></p>
41
  </div>
 
 
42
  </section>
43
+ <section class="process-panel lab-panel" aria-labelledby="diffusion-title">
44
+ <div class="process-heading"><div class="panel-heading"><div>
45
+ <h2 id="diffusion-title">Forward process: image to noise</h2>
46
+ </div></div>
47
+ <span class="equation">x<sub>t</sub> = √α̅<sub>t</sub> x<sub>0</sub> + √(1 − α̅<sub>t</sub>) ε</span>
48
+ </div>
49
+ <output id="forward-progress-label" class="step-progress">0/1000</output>
50
+ <div id="diffusion-track" class="image-track" aria-label="Forward diffusion checkpoints"></div>
51
+ </section>
52
+ <section class="process-panel lab-panel" aria-labelledby="denoising-title">
53
+ <div class="process-heading"><div class="panel-heading"><div>
54
+ <h2 id="denoising-title">Reverse process: noise to face</h2></div></div>
55
+ <p class="reverse-equation">x<sub>t−1</sub> = 1/√α<sub>t</sub> (x<sub>t</sub> − β<sub>t</sub>/√(1−α̅<sub>t</sub>) ε<sub>θ</sub>(x<sub>t</sub>,t)) + σ<sub>t</sub>z</p>
56
  </div>
57
+ <output id="reverse-progress-label" class="step-progress">0/1000</output>
58
+ <div id="denoising-track" class="image-track" aria-label="Reverse denoising checkpoints"></div>
59
  </section>
60
  </main>
 
 
61
  </div>
62
+ <input id="image-upload" type="file" accept="image/*" hidden>
63
  <script src="app.js" defer></script>
64
  </body>
65
  </html>
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ diffusers>=0.30.0
2
+ matplotlib>=3.8.0
3
+ Pillow>=10.0.0
4
+ safetensors>=0.4.0
5
+ torch>=2.1.0
server.py ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import base64
2
+ import binascii
3
+ import json
4
+ import mimetypes
5
+ import secrets
6
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
7
+ from io import BytesIO
8
+ from pathlib import Path
9
+ from urllib.parse import urlparse
10
+
11
+ from generate import CHECKPOINTS, MODEL_ID, generate_images, is_model_loaded, load_model
12
+
13
+
14
+ ROOT = Path(__file__).resolve().parent
15
+
16
+
17
+ def encode_frame(image):
18
+ buffer = BytesIO()
19
+ image.save(buffer, format="JPEG", quality=86)
20
+ return base64.b64encode(buffer.getvalue()).decode("ascii")
21
+
22
+
23
+ def generate_frames(seed, checkpoints=CHECKPOINTS, initial_image=None):
24
+ yield {"type": "status", "status": "loading", "model": MODEL_ID}
25
+
26
+ try:
27
+ for step, image in generate_images(seed, checkpoints=checkpoints, initial_image=initial_image):
28
+ yield {"type": "progress", "step": step, "total": 1000}
29
+ if image is not None:
30
+ yield {"type": "frame", "step": step, "image": encode_frame(image)}
31
+
32
+ _, _, device = load_model()
33
+ yield {"type": "complete", "seed": seed, "device": device}
34
+ except Exception as error:
35
+ yield {"type": "error", "message": str(error)}
36
+
37
+
38
+ class DiffusionHandler(BaseHTTPRequestHandler):
39
+ def send_bytes(self, status, content_type, body):
40
+ self.send_response(status)
41
+ self.send_header("Content-Type", content_type)
42
+ self.send_header("Content-Length", str(len(body)))
43
+ self.send_header("Cache-Control", "no-store")
44
+ self.end_headers()
45
+ self.wfile.write(body)
46
+
47
+ def do_GET(self):
48
+ path = urlparse(self.path).path
49
+ if path == "/api/health":
50
+ body = json.dumps({"model": MODEL_ID, "loaded": is_model_loaded()}).encode()
51
+ self.send_bytes(200, "application/json", body)
52
+ return
53
+
54
+ relative_path = "index.html" if path == "/" else path.lstrip("/")
55
+ if relative_path not in {"index.html", "app.js", "styles.css"}:
56
+ self.send_error(404)
57
+ return
58
+
59
+ file_path = ROOT / relative_path
60
+ content_type = mimetypes.guess_type(file_path.name)[0] or "application/octet-stream"
61
+ self.send_bytes(200, content_type, file_path.read_bytes())
62
+
63
+ def do_POST(self):
64
+ if urlparse(self.path).path != "/api/generate":
65
+ self.send_error(404)
66
+ return
67
+
68
+ try:
69
+ length = int(self.headers.get("Content-Length", "0"))
70
+ request = json.loads(self.rfile.read(length) or b"{}")
71
+ seed = int(request.get("seed", secrets.randbelow(2**31)))
72
+ if seed < 0 or seed >= 2**63:
73
+ raise ValueError("Seed must be between 0 and 2^63 - 1.")
74
+ checkpoints = request.get("checkpoints", CHECKPOINTS)
75
+ if (
76
+ not isinstance(checkpoints, list)
77
+ or not 2 <= len(checkpoints) <= 12
78
+ or any(type(step) is not int or step < 0 or step > 1000 for step in checkpoints)
79
+ ):
80
+ raise ValueError("Checkpoints must be a list of 2 to 12 integers from 0 to 1000.")
81
+ checkpoints = tuple(sorted(set(checkpoints)))
82
+ if checkpoints[0] != 0 or checkpoints[-1] != 1000:
83
+ raise ValueError("Checkpoints must include 0 and 1000.")
84
+ initial_image = None
85
+ encoded_image = request.get("initial_image")
86
+ if encoded_image is not None:
87
+ if not isinstance(encoded_image, str) or len(encoded_image) > 3_000_000:
88
+ raise ValueError("The forward-noise image is invalid or too large.")
89
+ from PIL import Image
90
+
91
+ encoded_image = encoded_image.partition(",")[2]
92
+ initial_image = Image.open(BytesIO(base64.b64decode(encoded_image, validate=True)))
93
+ initial_image.load()
94
+ except (ValueError, TypeError, json.JSONDecodeError, binascii.Error, OSError) as error:
95
+ self.send_bytes(400, "application/json", json.dumps({"error": str(error)}).encode())
96
+ return
97
+
98
+ self.send_response(200)
99
+ self.send_header("Content-Type", "application/x-ndjson; charset=utf-8")
100
+ self.send_header("Cache-Control", "no-store")
101
+ self.send_header("X-Accel-Buffering", "no")
102
+ self.end_headers()
103
+ try:
104
+ for event in generate_frames(seed, checkpoints, initial_image):
105
+ self.wfile.write((json.dumps(event) + "\n").encode())
106
+ self.wfile.flush()
107
+ except (BrokenPipeError, ConnectionResetError):
108
+ pass
109
+
110
+ def log_message(self, format_string, *args):
111
+ print(f"{self.address_string()} - {format_string % args}")
112
+
113
+
114
+ class DiffusionServer(ThreadingHTTPServer):
115
+ daemon_threads = True
116
+
117
+
118
+ if __name__ == "__main__":
119
+ server = DiffusionServer(("127.0.0.1", 8000), DiffusionHandler)
120
+ print(f"Diffusion Lab: http://{server.server_address[0]}:{server.server_address[1]}")
121
+ print(f"Model: {MODEL_ID} (loaded on first generation request)")
122
+ server.serve_forever()
styles.css CHANGED
@@ -1,179 +1,70 @@
1
- @import url('https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=DM+Sans:wght@400;500;600;700&family=Space+Grotesk:wght@400;500;600;700&display=swap');
2
-
3
- :root {
4
- color-scheme: light;
5
- --paper: #f3f2ec;
6
- --surface: #fbfaf6;
7
- --ink: #222b25;
8
- --muted: #778078;
9
- --line: #d8d9d0;
10
- --green: #315b48;
11
- --green-bright: #9ace82;
12
- --coral: #db7358;
13
- --mono: 'DM Mono', monospace;
14
- --sans: 'DM Sans', sans-serif;
15
- --display: 'Space Grotesk', sans-serif;
16
- }
17
-
18
- * { box-sizing: border-box; }
19
- html { min-width: 320px; background: var(--paper); }
20
- body { margin: 0; color: var(--ink); font-family: var(--sans); background: var(--paper); }
21
- button, input { font: inherit; }
22
- button { color: inherit; }
23
- .app-shell { max-width: 1440px; min-height: 100vh; margin: 0 auto; padding: 0 6.1%; background-color: var(--paper); background-image: radial-gradient(#59644d12 .7px, transparent .7px); background-size: 8px 8px; }
24
- .topbar { height: 76px; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--line); }
25
- .brand { display: inline-flex; align-items: center; gap: 11px; color: var(--ink); text-decoration: none; font: 700 16px var(--display); letter-spacing: 0; }
26
- .brand-light { color: var(--muted); font-weight: 500; }
27
- .brand-mark { width: 24px; height: 24px; display: grid; grid-template-columns: repeat(2, 1fr); gap: 3px; transform: rotate(-9deg); }
28
- .brand-mark i { display: block; border-radius: 2px; background: var(--green); }
29
- .brand-mark i:nth-child(2) { background: #91b680; }
30
- .brand-mark i:nth-child(3) { background: var(--coral); }
31
- .brand-mark i:nth-child(4) { background: #d8a26d; }
32
- .topbar-right { display: flex; align-items: center; gap: 11px; color: #68736b; font: 10px var(--mono); letter-spacing: 0; }
33
- .live-dot { width: 7px; height: 7px; border-radius: 50%; background: #82ad6b; box-shadow: 0 0 0 3px #82ad6b22; }
34
- .topbar-rule { height: 13px; border-left: 1px solid #c4c7bd; margin: 0 4px; }
35
- main { padding-bottom: 48px; }
36
- .intro { display: grid; grid-template-columns: 1fr minmax(270px, 390px); align-items: end; gap: 48px; padding: 54px 0 42px; }
37
- .eyebrow { margin: 0; color: var(--muted); font: 10px var(--mono); letter-spacing: 0; }
38
- .eyebrow > span { margin-right: 10px; color: var(--coral); }
39
- h1 { margin: 17px 0 12px; font: 600 clamp(44px, 5vw, 68px)/.99 var(--display); letter-spacing: 0; }
40
- h1 span { color: var(--green); }
41
- .dek { max-width: 490px; margin: 0; color: #616b63; font-size: 14px; line-height: 1.7; }
42
- .equation-note { border-left: 2px solid var(--coral); padding: 5px 0 5px 20px; display: grid; gap: 11px; margin-bottom: 7px; }
43
- .equation-label, .equation-foot { color: var(--muted); font: 10px var(--mono); }
44
- .equation { color: var(--green); font: 500 17px var(--display); white-space: nowrap; }
45
- .equation sub { font-size: 10px; }
46
- .equation-foot b { color: var(--coral); padding: 0 5px; }
47
- .workbench { background: var(--surface); border: 1px solid var(--line); box-shadow: 0 14px 40px #48523c0b; padding: 22px 25px 19px; }
48
- .workbench-head { display: flex; align-items: center; justify-content: space-between; gap: 20px; margin-bottom: 20px; }
49
- .section-title { display: flex; align-items: center; gap: 13px; }
50
- .section-index { width: 34px; height: 34px; border: 1px solid #d2d8cc; border-radius: 50%; display: grid; place-items: center; color: var(--green); font: 11px var(--mono); }
51
- .section-title .eyebrow { font-size: 9px; margin-bottom: 3px; }
52
- h2 { margin: 0; font: 600 20px var(--display); letter-spacing: 0; }
53
- .controls { display: flex; align-items: center; gap: 10px; }
54
- .segmented { display: flex; padding: 3px; gap: 2px; border: 1px solid var(--line); background: #f3f2ed; border-radius: 4px; }
55
- .segment { min-height: 31px; padding: 0 12px; border: 0; border-radius: 2px; background: transparent; color: #687169; cursor: pointer; font: 11px var(--mono); }
56
- .segment.active { color: white; background: var(--green); }
57
- .icon-button, .step-button { display: grid; place-items: center; padding: 0; border: 1px solid var(--line); background: var(--surface); cursor: pointer; transition: background .18s, border-color .18s; }
58
- .icon-button { width: 38px; height: 38px; border-radius: 50%; }
59
- .icon-button:hover, .step-button:hover { border-color: var(--green); background: #eff3eb; }
60
- .icon-button svg { width: 19px; height: 19px; fill: var(--green); }
61
- .icon-button .pause-icon { fill: none; stroke: var(--green); stroke-width: 1.7; }
62
- .hidden { display: none; }
63
- .process-track { display: grid; grid-template-columns: minmax(0, 1fr) 28px minmax(0, 1fr) 28px minmax(0, 1fr); align-items: center; gap: 8px; }
64
- .image-panel { min-width: 0; }
65
- .panel-topline { display: flex; justify-content: space-between; align-items: center; padding-bottom: 8px; color: var(--muted); font: 9px var(--mono); }
66
- .panel-tag { color: var(--green); border: 1px solid #d5dbd0; padding: 3px 7px; border-radius: 2px; }
67
- .panel-tag sub { font-size: 8px; }
68
- .canvas-wrap { position: relative; overflow: hidden; background: #e8e5da; border: 1px solid #dedfd7; aspect-ratio: 1.52; }
69
- .canvas-wrap canvas { width: 100%; height: 100%; display: block; object-fit: cover; image-rendering: auto; }
70
- .state-wrap { background: #c5c8bb; }
71
- .output-wrap { background: #d9e2d6; }
72
- .upload-control { position: absolute; right: 8px; bottom: 8px; display: flex; gap: 6px; align-items: center; min-height: 29px; padding: 0 9px; color: var(--ink); background: #fbfaf6ed; border: 1px solid #ffffffb8; border-radius: 3px; cursor: pointer; font: 9px var(--mono); transition: background .18s; }
73
- .upload-control:hover { background: white; }
74
- .upload-control svg { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; }
75
- .state-corner, .output-stamp { position: absolute; left: 8px; bottom: 8px; padding: 5px 7px; color: white; background: #26362dbd; font: 9px var(--mono); }
76
- .output-stamp { left: auto; right: 8px; color: var(--green); background: #f7fbf1e8; border: 1px solid #ffffffad; }
77
- .panel-caption { min-height: 45px; padding-top: 8px; display: flex; flex-direction: column; gap: 3px; }
78
- .panel-caption strong { overflow: hidden; color: var(--ink); font: 600 12px var(--display); text-overflow: ellipsis; white-space: nowrap; }
79
- .panel-caption span { color: var(--muted); font: 9px var(--mono); }
80
- .flow-arrow { display: grid; place-items: center; }
81
- .flow-arrow span { width: 19px; height: 1px; position: relative; background: #c3c8bd; }
82
- .flow-arrow span::after { content: ''; position: absolute; top: -3px; right: 0; width: 6px; height: 6px; border-top: 1px solid #929b8f; border-right: 1px solid #929b8f; transform: rotate(45deg); }
83
- .reverse-arrow span { transform: rotate(180deg); }
84
- .scrubber-row { display: grid; grid-template-columns: 34px minmax(0, 1fr) 34px; gap: 13px; align-items: center; margin-top: 8px; }
85
- .step-button { width: 32px; height: 32px; border-radius: 50%; }
86
- .step-button svg { width: 17px; height: 17px; fill: none; stroke: var(--green); stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; }
87
- .scrubber-labels { display: flex; justify-content: space-between; align-items: center; color: #8b928a; font: 8px var(--mono); }
88
- .scrubber-labels #step-readout { color: var(--green); font-size: 10px; }
89
- .scrubber-labels #step-readout b { color: #899188; font-weight: 400; }
90
- #step-slider { appearance: none; display: block; width: 100%; height: 14px; margin: 8px 0 0; background: transparent; cursor: pointer; }
91
- #step-slider::-webkit-slider-runnable-track { height: 3px; background: linear-gradient(90deg, var(--green-bright), #d7d8ce 0%); border-radius: 2px; }
92
- #step-slider::-moz-range-track { height: 3px; background: #d7d8ce; border-radius: 2px; }
93
- #step-slider::-webkit-slider-thumb { appearance: none; width: 13px; height: 13px; margin-top: -5px; border: 2px solid var(--surface); border-radius: 50%; background: var(--coral); box-shadow: 0 0 0 1px var(--coral); }
94
- #step-slider::-moz-range-thumb { width: 10px; height: 10px; border: 2px solid var(--surface); border-radius: 50%; background: var(--coral); box-shadow: 0 0 0 1px var(--coral); }
95
- .tick-row { display: flex; justify-content: space-between; padding: 1px 5px 0; }
96
- .tick-row span { width: 1px; height: 4px; background: #c7cbc1; }
97
- .checkpoint-strip { display: grid; grid-template-columns: repeat(8, minmax(0, 1fr)); gap: 8px; margin-top: 15px; }
98
- .checkpoint { min-width: 0; padding: 4px; text-align: left; background: #f5f4ef; border: 1px solid #e4e4dd; border-radius: 3px; cursor: pointer; transition: border-color .2s, transform .2s; }
99
- .checkpoint:hover { transform: translateY(-2px); border-color: #a8b8a2; }
100
- .checkpoint.active { border-color: var(--coral); box-shadow: 0 0 0 1px var(--coral); }
101
- .checkpoint canvas { display: block; width: 100%; aspect-ratio: 1.48; object-fit: cover; background: #e7e5dc; }
102
- .checkpoint span { display: block; overflow: hidden; margin-top: 5px; color: #69736b; font: 8px var(--mono); text-overflow: ellipsis; white-space: nowrap; }
103
- .checkpoint.active span { color: var(--green); }
104
- .lower-grid { display: grid; grid-template-columns: 1.05fr .95fr; gap: 50px; padding: 35px 6px 5px; }
105
- .process-explainer { max-width: 500px; }
106
- .process-explainer h2 { margin: 9px 0 7px; font-size: 19px; }
107
- .process-explainer > p:last-child { max-width: 530px; margin: 0; color: #667168; font-size: 12px; line-height: 1.7; }
108
- .legend-panel { border-left: 1px solid var(--line); padding-left: 22px; }
109
- .legend-heading { display: flex; justify-content: space-between; gap: 15px; margin-bottom: 13px; color: var(--muted); font: 9px var(--mono); }
110
- .legend-heading span:last-child { color: var(--green); }
111
- .legend-item { display: flex; align-items: center; gap: 8px; margin: 8px 0; color: #6d766f; font-size: 10px; }
112
- .legend-item b { color: var(--ink); font-weight: 600; }
113
- .legend-swatch { width: 9px; height: 9px; border-radius: 2px; }
114
- .legend-swatch.signal { background: var(--green); }
115
- .legend-swatch.noise { background: var(--coral); }
116
- .legend-swatch.estimate { background: #91b680; }
117
- .model-note { margin: 15px 0 0; padding-top: 10px; border-top: 1px solid #e1e2da; color: #818981; font: 9px/1.6 var(--mono); }
118
- .model-note sub { font-size: 7px; }
119
- .model-note span { color: var(--coral); padding: 0 3px; }
120
- footer { min-height: 52px; display: flex; justify-content: space-between; align-items: center; border-top: 1px solid var(--line); color: #7e877f; font: 8px var(--mono); }
121
- .footer-dot { color: var(--coral); padding: 0 4px; }
122
-
123
- @media (min-width: 1200px) {
124
- .app-shell { padding-right: 7.4%; padding-left: 7.4%; }
125
- .workbench { padding: 25px 30px 21px; }
126
- .canvas-wrap { aspect-ratio: 1.64; }
127
- }
128
- @media (max-width: 760px) {
129
- .app-shell { padding: 0 5%; }
130
- .topbar { height: 64px; }
131
- .topbar-right { gap: 7px; font-size: 8px; }
132
- .intro { grid-template-columns: 1fr; gap: 22px; padding: 38px 0 29px; }
133
- h1 { font-size: 53px; }
134
- .dek { font-size: 13px; }
135
- .equation-note { padding-left: 14px; gap: 8px; }
136
- .equation { font-size: 15px; }
137
- .workbench { padding: 17px 14px 15px; }
138
- .workbench-head { align-items: flex-start; }
139
- .section-title { gap: 9px; }
140
- h2 { font-size: 17px; }
141
- .section-index { width: 30px; height: 30px; }
142
- .controls { gap: 6px; }
143
- .segment { padding: 0 8px; font-size: 10px; }
144
- .icon-button { width: 34px; height: 34px; }
145
- .process-track { grid-template-columns: minmax(0, 1fr) 14px minmax(0, 1fr); gap: 6px; }
146
- .process-track .output-panel { grid-column: 1; }
147
- .process-track .reverse-arrow { display: none; }
148
- .process-track .output-panel { margin-top: 6px; }
149
- .process-track .state-panel { grid-column: 3; grid-row: 1; }
150
- .process-track .source-panel { grid-column: 1; grid-row: 1; }
151
- .flow-arrow:not(.reverse-arrow) { grid-column: 2; grid-row: 1; }
152
- .process-track .output-panel { grid-column: 1 / 4; grid-row: 2; width: calc(50% - 10px); }
153
- .process-track .output-panel .canvas-wrap { aspect-ratio: 1.52; }
154
- .panel-topline { font-size: 8px; }
155
- .panel-caption strong { font-size: 11px; }
156
- .panel-caption span { font-size: 8px; }
157
- .checkpoint-strip { gap: 5px; margin-top: 12px; }
158
- .checkpoint { padding: 3px; }
159
- .checkpoint span { font-size: 7px; }
160
- .scrubber-row { grid-template-columns: 28px minmax(0, 1fr) 28px; gap: 7px; }
161
- .step-button { width: 28px; height: 28px; }
162
- .scrubber-labels { font-size: 7px; }
163
- .scrubber-labels #step-readout { font-size: 9px; }
164
- .lower-grid { grid-template-columns: 1fr; gap: 22px; padding: 27px 3px 5px; }
165
- .legend-panel { border-left: 0; border-top: 1px solid var(--line); padding: 17px 0 0; }
166
- }
167
- @media (max-width: 410px) {
168
- .topbar-right { font-size: 7px; gap: 5px; }
169
- .brand { font-size: 14px; }
170
- .brand-mark { width: 21px; height: 21px; }
171
- .workbench-head { flex-wrap: wrap; }
172
- .controls { margin-left: auto; }
173
- .canvas-wrap { aspect-ratio: 1.2; }
174
- .process-track .output-panel { width: calc(60% - 7px); }
175
- .panel-caption { min-height: 42px; }
176
- .panel-caption span { white-space: normal; }
177
- .checkpoint span { font-size: 6px; }
178
- footer { align-items: flex-start; flex-direction: column; justify-content: center; gap: 5px; padding: 10px 0; }
179
- }
 
1
+ :root { color-scheme: light; --paper:#f3f2ec; --surface:#fbfaf6; --ink:#222b25; --muted:#778078; --line:#d8d9d0; --green:#315b48; --lime:#9ace82; --coral:#db7358; --mono:'DM Mono',monospace; --sans:'DM Sans',sans-serif; --display:'Space Grotesk',sans-serif; }
2
+ * { box-sizing:border-box; }
3
+ html { min-width:320px; background:var(--paper); }
4
+ body { margin:0; color:var(--ink); font-family:var(--sans); background:var(--paper); }
5
+ button,input { font:inherit; }
6
+ button { color:inherit; }
7
+ .app-shell { max-width:1440px; min-height:100vh; margin:auto; padding:0 6%; background-image:radial-gradient(#59644d12 .7px,transparent .7px); background-size:8px 8px; }
8
+ .topbar { height:76px; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--line); }
9
+ .brand { display:inline-flex; align-items:center; gap:11px; color:var(--ink); text-decoration:none; font:700 16px var(--display); }
10
+ .brand-light { color:var(--muted); font-weight:500; }
11
+ .brand-mark { width:24px; height:24px; display:grid; grid-template-columns:repeat(2,1fr); gap:3px; transform:rotate(-9deg); }
12
+ .brand-mark i { border-radius:2px; background:var(--green); }.brand-mark i:nth-child(2){background:#91b680}.brand-mark i:nth-child(3){background:var(--coral)}.brand-mark i:nth-child(4){background:#d8a26d}
13
+ .topbar-right { display:flex; align-items:center; gap:11px; color:#68736b; font:10px var(--mono); }
14
+ .live-dot { width:7px; height:7px; border-radius:50%; background:#82ad6b; box-shadow:0 0 0 3px #82ad6b22; }.topbar-rule{height:13px;border-left:1px solid #c4c7bd;margin:0 4px}
15
+ main { display:grid; gap:22px; padding:34px 0 42px; }
16
+ .lab-panel { padding:24px 26px; border:1px solid var(--line); background:var(--surface); box-shadow:0 12px 34px #48523c0a; }
17
+ .control-panel { display:block; }
18
+ .control-panel h1 { margin-bottom:18px; font-size:20px; }
19
+ .panel-heading { display:flex; align-items:center; gap:13px; min-width:0; }
20
+ .section-index { flex:0 0 auto; width:34px; height:34px; display:grid; place-items:center; border:1px solid #d2d8cc; border-radius:50%; color:var(--green); font:11px var(--mono); }
21
+ .eyebrow { margin:0 0 4px; color:var(--muted); font:9px var(--mono); letter-spacing:.04em; }
22
+ h1,h2 { margin:0; font:600 20px var(--display); } h1{font-size:18px}
23
+ .config-rows { max-width:820px; display:grid; gap:8px; }
24
+ .config-row { display:grid; grid-template-columns:110px minmax(0,1fr) 70px; align-items:center; min-height:40px; }
25
+ .config-row label,.row-label-button { align-self:stretch; display:flex; align-items:center; padding:0 12px; border:1px solid #cfd3c9; border-right:0; border-radius:3px 0 0 3px; color:var(--green); background:#f0f2eb; font:10px var(--mono); }
26
+ .config-row input { min-width:0; width:100%; height:40px; padding:0 12px; color:var(--ink); border:1px solid #cfd3c9; border-radius:0 3px 3px 0; outline:none; background:white; font:11px var(--mono); }
27
+ .config-row input:focus { position:relative; border-color:var(--green); box-shadow:inset 0 0 0 1px var(--green); }
28
+ .config-row input[readonly] { color:#687169; background:#f8f7f2; }
29
+ .config-row #model-input,.config-row #seed-input { grid-column:2 / 4; }
30
+ .config-row:has(#apply-timesteps) input { border-radius:0; }
31
+ .config-row #apply-timesteps,.process-button { border:1px solid var(--green); color:white; background:var(--green); cursor:pointer; font:10px var(--mono); }
32
+ .config-row #apply-timesteps { align-self:stretch; border-radius:0 3px 3px 0; }
33
+ .config-row #apply-timesteps:hover,.process-button:hover:not(:disabled){background:#244737}
34
+ .row-label-button { cursor:pointer; text-align:left; }
35
+ .row-label-button:hover { color:white; background:var(--green); }
36
+ .config-help { display:flex; justify-content:space-between; gap:20px; padding-left:110px; color:var(--muted); font:8px/1.5 var(--mono); }
37
+ .field-error { min-height:12px; margin:0; padding-left:110px; color:#b34e38; font:8px/1.5 var(--mono); }
38
+ .equation-note { display:grid; gap:9px; padding:5px 0 5px 18px; border-left:2px solid var(--coral); }
39
+ .equation-label { color:var(--muted); font:9px var(--mono); }.equation{color:var(--green);font:500 15px var(--display);white-space:nowrap}.equation sub{font-size:9px}
40
+ .process-heading { display:flex; align-items:center; justify-content:space-between; gap:25px; margin-bottom:18px; }
41
+ .process-heading>p { max-width:330px; margin:0; color:var(--muted); text-align:right; font:10px/1.5 var(--mono); }
42
+ .process-heading .reverse-equation { max-width:520px; color:var(--green); font:500 13px/1.6 var(--display); }
43
+ .reverse-equation sub { font-size:8px; }
44
+ .step-progress { flex:0 0 auto; padding:6px 10px; border:1px solid #d6dacf; border-radius:3px; color:var(--coral); background:#f5f4ef; font:500 11px var(--mono); white-space:nowrap; }
45
+ .image-track { display:grid; grid-template-columns:repeat(var(--stage-count,8),minmax(100px,1fr)); gap:10px; overflow-x:auto; padding:2px 2px 10px; }
46
+ .stage-card { position:relative; min-width:100px; transition:opacity .2s,transform .2s; }
47
+ .stage-card.is-pending .stage-canvas canvas { opacity:0; }
48
+ .stage-card.is-pending .stage-canvas::after { content:'WAITING'; position:absolute; inset:0; display:grid; place-items:center; color:#92998f; font:8px var(--mono); }
49
+ .action-slot { position:relative; z-index:2; height:34px; display:flex; align-items:flex-start; gap:5px; }
50
+ .action-slot.reverse-actions { width:max-content; left:100%; transform:translateX(-100%); }
51
+ .action-slot.status-right { width:max-content; left:100%; transform:translateX(-100%); }
52
+ .process-button { min-width:76px; height:28px; padding:0 12px; border-radius:3px; }
53
+ .reverse-actions .process-button { min-width:auto; padding:0 8px; font-size:8px; white-space:nowrap; }
54
+ .process-button:disabled { opacity:.6; cursor:progress; }
55
+ .stage-canvas { position:relative; overflow:hidden; aspect-ratio:1; border:1px solid #dedfd7; background:linear-gradient(135deg,#e7e5dc,#d9ddd4); transition:border-color .2s,box-shadow .2s; }
56
+ .stage-canvas canvas { display:block; width:100%; height:100%; object-fit:cover; }
57
+ .stage-card.is-active { transform:translateY(-3px); }.stage-card.is-active .stage-canvas{border-color:var(--coral);box-shadow:0 0 0 2px var(--coral),0 8px 20px #db735824}
58
+ .change-image { position:absolute; right:6px; bottom:6px; min-height:25px; padding:0 7px; border:1px solid #ffffffb8; border-radius:3px; background:#fbfaf6e8; cursor:pointer; font:8px var(--mono); }
59
+ .noise-actions { position:absolute; right:6px; bottom:6px; z-index:2; display:flex; gap:4px; }
60
+ .noise-actions .process-button { min-width:auto; height:25px; padding:0 7px; border-color:#ffffffb8; color:var(--ink); background:#fbfaf6e8; font-size:7px; }
61
+ .noise-actions .process-button:hover:not(:disabled) { color:white; background:var(--green); }
62
+ .stage-caption { display:flex; align-items:center; justify-content:space-between; gap:5px; min-height:25px; padding-top:7px; }.stage-caption strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font:600 10px var(--display)}.stage-caption span{flex:0 0 auto;margin-left:auto;color:var(--green);font:600 13px var(--mono)}.stage-caption span:only-child{margin-right:auto}
63
+ .hover-preview { position:fixed; z-index:50; display:none; width:256px; height:256px; padding:0; border:2px solid var(--surface); background:#e7e5dc; box-shadow:0 12px 38px #25312852; pointer-events:none; }
64
+ .hover-preview canvas { display:block; width:256px; height:256px; }
65
+ .hover-preview.is-visible { display:block; }
66
+ .model-status { margin:8px 0 0; padding-top:10px; border-top:1px solid #e1e2da; color:#737d74; font:9px/1.5 var(--mono); }
67
+ .model-note { padding:4px 7px; color:#818981; font:9px/1.6 var(--mono); }.model-note span{display:block;margin-bottom:3px;color:var(--green)}
68
+ footer { min-height:52px; display:flex; align-items:center; justify-content:space-between; border-top:1px solid var(--line); color:#7e877f; font:8px var(--mono); }.footer-dot{color:var(--coral);padding:0 4px}
69
+ @media(max-width:900px){.image-track{grid-template-columns:repeat(var(--stage-count,8),minmax(112px,1fr))}}
70
+ @media(max-width:620px){.app-shell{padding:0 4%}.topbar{height:64px}.topbar-right{font-size:7px;gap:6px}.lab-panel{padding:18px 14px}.config-row{grid-template-columns:82px minmax(0,1fr) 58px}.config-help{padding-left:82px;flex-direction:column;gap:2px}.field-error{padding-left:82px}.process-heading{align-items:flex-start;flex-direction:column;gap:10px}.process-heading>p{text-align:left}.image-track{grid-template-columns:repeat(var(--stage-count,8),124px)}footer{align-items:flex-start;flex-direction:column;justify-content:center;gap:5px}}