lcccluck commited on
Commit
9894238
·
verified ·
1 Parent(s): a885af1

Add QuickDraw diffusion model and app code

Browse files
README.md ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ tags:
4
+ - quickdraw
5
+ - sketch-generation
6
+ - diffusion
7
+ - ddpm
8
+ - class-conditional
9
+ - classifier-free-guidance
10
+ library_name: pytorch
11
+ pipeline_tag: image-to-image
12
+ ---
13
+
14
+ # QuickDraw Sketch Diffusion
15
+
16
+ This repository contains a small class-conditional DDPM trained on rasterized Google QuickDraw sketches. The model generates 64x64 grayscale sketches with a black background and white strokes.
17
+
18
+ It also includes a local FastAPI web app for selecting a class, generating sketches with classifier-free guidance, and optionally recognizing the generated image with the bundled 100-class CNN classifier.
19
+
20
+ ## Files
21
+
22
+ - `models/diffusion/checkpoint_step_500000.pt`: 500k-step class-conditional diffusion checkpoint.
23
+ - `train_quickdraw_ddpm.py`: training, sampling, QuickDraw download, rasterization, and model definitions.
24
+ - `quickdraw_app/server.py`: FastAPI generation and recognition API.
25
+ - `quickdraw_app/static/`: browser UI.
26
+ - `quickdraw_app/cnn_classifier.py`: optional CNN recognition helper.
27
+ - `quickdraw_app/models/cnn_residual_100cls_64/best_model.pt`: bundled 100-class 64x64 CNN classifier.
28
+ - `assets/samples_step_500000.png`: sample grid from the diffusion checkpoint.
29
+
30
+ ## Model Details
31
+
32
+ - Dataset: Google QuickDraw `full/simplified` drawings.
33
+ - Classes: 100 built-in QuickDraw classes.
34
+ - Training set used: 50,000 samples per class, about 5 million sketches total.
35
+ - Image size: 64x64.
36
+ - Channels: 1 grayscale channel.
37
+ - Diffusion steps: 200.
38
+ - Architecture: compact conditional U-Net.
39
+ - Conditioning: class embedding plus classifier-free guidance.
40
+ - Checkpoint step: 500,000.
41
+ - Default guidance scale: 3.0.
42
+ - Output convention: black background, white sketch strokes.
43
+
44
+ ## Quick Start
45
+
46
+ Install dependencies:
47
+
48
+ ```bash
49
+ pip install -r requirements.txt
50
+ ```
51
+
52
+ Run the local web app:
53
+
54
+ ```bash
55
+ uvicorn quickdraw_app.server:app --host 127.0.0.1 --port 7860
56
+ ```
57
+
58
+ Then open:
59
+
60
+ ```text
61
+ http://127.0.0.1:7860
62
+ ```
63
+
64
+ The first generation loads the checkpoint and may take longer. Later requests reuse the loaded model.
65
+
66
+ ## Python Sampling
67
+
68
+ ```python
69
+ import torch
70
+ from torchvision.utils import save_image
71
+
72
+ from train_quickdraw_ddpm import SmallConditionalUNet, make_schedule, pick_device, sample
73
+
74
+ checkpoint_path = "models/diffusion/checkpoint_step_500000.pt"
75
+ device = pick_device()
76
+ checkpoint = torch.load(checkpoint_path, map_location=device, weights_only=False)
77
+
78
+ classes = checkpoint["classes"]
79
+ image_size = int(checkpoint["image_size"])
80
+ timesteps = int(checkpoint["timesteps"])
81
+ base_channels = int(checkpoint["base_channels"])
82
+
83
+ model = SmallConditionalUNet(len(classes), base_channels=base_channels).to(device)
84
+ model.load_state_dict(checkpoint.get("model_unwrapped") or checkpoint["model"])
85
+ model.eval()
86
+
87
+ schedule = make_schedule(timesteps, device)
88
+ class_name = "cat"
89
+ label = torch.tensor([classes.index(class_name)], device=device)
90
+
91
+ with torch.no_grad():
92
+ image = sample(
93
+ model,
94
+ label,
95
+ image_size,
96
+ schedule,
97
+ timesteps,
98
+ device,
99
+ guidance_scale=3.0,
100
+ )
101
+
102
+ save_image((image + 1) / 2, "cat.png")
103
+ ```
104
+
105
+ ## Training Example
106
+
107
+ The final run used 100 classes, 50,000 samples per class, 64x64 images, CFG dropout, and 500k optimization steps. A similar run can be started with:
108
+
109
+ ```bash
110
+ python train_quickdraw_ddpm.py \
111
+ --num-classes 100 \
112
+ --samples-per-class 50000 \
113
+ --image-size 64 \
114
+ --line-width 2 \
115
+ --batch-size 512 \
116
+ --steps 500000 \
117
+ --timesteps 200 \
118
+ --base-channels 64 \
119
+ --cfg-drop-prob 0.1 \
120
+ --guidance-scale 3.0 \
121
+ --data-parallel
122
+ ```
123
+
124
+ To resume:
125
+
126
+ ```bash
127
+ python train_quickdraw_ddpm.py \
128
+ --resume models/diffusion/checkpoint_step_500000.pt \
129
+ --steps 550000 \
130
+ --batch-size 512 \
131
+ --data-parallel
132
+ ```
133
+
134
+ ## Evaluation Note
135
+
136
+ Using the bundled 100-class 64x64 CNN classifier on 400 generated samples, the diffusion checkpoint reached about 36.00% top-1 and 71.75% top-5 agreement. This is a rough model-to-model sanity check, not a human preference score.
137
+
138
+ ## Limitations
139
+
140
+ This is a compact 64x64 sketch model. It is useful as a QuickDraw-style benchmark and interactive demo, but it is not a high-resolution image generator. Some classes are visually ambiguous, and CNN agreement can be poor for categories with similar silhouettes.
141
+
142
+ ## Data
143
+
144
+ The training script downloads examples from the public Google QuickDraw dataset:
145
+
146
+ ```text
147
+ https://storage.googleapis.com/quickdraw_dataset/full/simplified/{word}.ndjson
148
+ ```
assets/samples_step_500000.png ADDED
models/diffusion/checkpoint_step_500000.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:0a204dd947d02a4d2d6af0bae2c11c2f7231e1ff549aa811ec533e000df60218
3
+ size 60453322
quickdraw_app/README.md ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # QuickDraw Diffusion Web App
2
+
3
+ Run the local web UI from the repository root:
4
+
5
+ ```bash
6
+ uvicorn quickdraw_app.server:app --host 127.0.0.1 --port 7860
7
+ ```
8
+
9
+ By default it loads:
10
+
11
+ ```text
12
+ models/diffusion/checkpoint_step_500000.pt
13
+ ```
14
+
15
+ To use another checkpoint:
16
+
17
+ ```bash
18
+ QUICKDRAW_CHECKPOINT=/absolute/path/to/checkpoint.pt uvicorn quickdraw_app.server:app --host 127.0.0.1 --port 7860
19
+ ```
20
+
21
+ The recognition button uses the bundled 100-class 64x64 residual CNN:
22
+
23
+ ```text
24
+ quickdraw_app/models/cnn_residual_100cls_64/best_model.pt
25
+ ```
quickdraw_app/cnn_classifier.py ADDED
@@ -0,0 +1,271 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Embedded CNN image classifier from the sketch-model project."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import json
7
+ from functools import lru_cache
8
+ from io import BytesIO
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import numpy as np
13
+ import torch
14
+ from fastapi import HTTPException
15
+ from PIL import Image, UnidentifiedImageError
16
+ from torch import nn
17
+ from torch.nn import functional as F
18
+ from torchvision.models import ResNet18_Weights, resnet18
19
+
20
+ APP_DIR = Path(__file__).resolve().parent
21
+ MODEL_DIR = APP_DIR / "models" / "cnn_resnet18_20cls"
22
+ MODEL_PATH = MODEL_DIR / "best_model.pt"
23
+ LABEL_MAP_PATH = MODEL_DIR / "label_map.json"
24
+ QUICKDRAW100_MODEL_DIR = APP_DIR / "models" / "cnn_residual_100cls_64"
25
+ QUICKDRAW100_MODEL_PATH = QUICKDRAW100_MODEL_DIR / "best_model.pt"
26
+ QUICKDRAW100_LABEL_MAP_PATH = QUICKDRAW100_MODEL_DIR / "label_map.json"
27
+
28
+
29
+ class ResidualBlock(nn.Module):
30
+ def __init__(self, channels: int, dropout: float = 0.0):
31
+ super().__init__()
32
+ self.block = nn.Sequential(
33
+ nn.Conv2d(channels, channels, kernel_size=3, padding=1, bias=False),
34
+ nn.BatchNorm2d(channels),
35
+ nn.ReLU(),
36
+ nn.Dropout2d(dropout),
37
+ nn.Conv2d(channels, channels, kernel_size=3, padding=1, bias=False),
38
+ nn.BatchNorm2d(channels),
39
+ )
40
+ self.activation = nn.ReLU()
41
+
42
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
43
+ return self.activation(x + self.block(x))
44
+
45
+
46
+ class ResidualCNN(nn.Module):
47
+ def __init__(self, num_classes: int):
48
+ super().__init__()
49
+ self.features = nn.Sequential(
50
+ nn.Conv2d(1, 48, kernel_size=3, padding=1, bias=False),
51
+ nn.BatchNorm2d(48),
52
+ nn.ReLU(),
53
+ ResidualBlock(48, dropout=0.05),
54
+ nn.MaxPool2d(2),
55
+ nn.Conv2d(48, 96, kernel_size=3, padding=1, bias=False),
56
+ nn.BatchNorm2d(96),
57
+ nn.ReLU(),
58
+ ResidualBlock(96, dropout=0.05),
59
+ nn.MaxPool2d(2),
60
+ nn.Conv2d(96, 192, kernel_size=3, padding=1, bias=False),
61
+ nn.BatchNorm2d(192),
62
+ nn.ReLU(),
63
+ ResidualBlock(192, dropout=0.08),
64
+ nn.MaxPool2d(2),
65
+ nn.Conv2d(192, 256, kernel_size=3, padding=1, bias=False),
66
+ nn.BatchNorm2d(256),
67
+ nn.ReLU(),
68
+ ResidualBlock(256, dropout=0.08),
69
+ nn.AdaptiveAvgPool2d((1, 1)),
70
+ )
71
+ self.classifier = nn.Sequential(
72
+ nn.Flatten(),
73
+ nn.Dropout(0.35),
74
+ nn.Linear(256, num_classes),
75
+ )
76
+
77
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
78
+ return self.classifier(self.features(x))
79
+
80
+
81
+ def build_model(
82
+ model_name: str,
83
+ num_classes: int,
84
+ pretrained: bool = False,
85
+ freeze_backbone: bool = False,
86
+ ) -> nn.Module:
87
+ if model_name == "resnet18":
88
+ weights = ResNet18_Weights.DEFAULT if pretrained else None
89
+ model = resnet18(weights=weights)
90
+ model.conv1 = nn.Conv2d(1, 64, kernel_size=3, stride=1, padding=1, bias=False)
91
+ model.maxpool = nn.Identity()
92
+ model.fc = nn.Linear(model.fc.in_features, num_classes)
93
+ if freeze_backbone:
94
+ for name, param in model.named_parameters():
95
+ param.requires_grad = name.startswith("fc.")
96
+ return model
97
+ if model_name == "residual-cnn":
98
+ return ResidualCNN(num_classes=num_classes)
99
+ raise ValueError(f"Unsupported embedded CNN model: {model_name}")
100
+
101
+
102
+ def pick_cnn_device() -> torch.device:
103
+ if torch.backends.mps.is_available():
104
+ return torch.device("mps")
105
+ if torch.cuda.is_available():
106
+ return torch.device("cuda")
107
+ return torch.device("cpu")
108
+
109
+
110
+ def load_label_map(label_map_path: Path = LABEL_MAP_PATH) -> dict[str, int]:
111
+ if not label_map_path.exists():
112
+ raise FileNotFoundError(f"Missing CNN label map: {label_map_path}")
113
+ return json.loads(label_map_path.read_text(encoding="utf-8"))
114
+
115
+
116
+ def labels_from_map(label_map: dict[str, int]) -> list[str]:
117
+ return [name for name, _ in sorted(label_map.items(), key=lambda item: item[1])]
118
+
119
+
120
+ @lru_cache(maxsize=1)
121
+ def image_predictor() -> tuple[nn.Module, list[str], torch.device, int]:
122
+ if not MODEL_PATH.exists():
123
+ raise FileNotFoundError(f"Missing CNN model: {MODEL_PATH}")
124
+ return load_predictor(MODEL_PATH, LABEL_MAP_PATH)
125
+
126
+
127
+ @lru_cache(maxsize=1)
128
+ def quickdraw100_predictor() -> tuple[nn.Module, list[str], torch.device, int]:
129
+ return load_predictor(QUICKDRAW100_MODEL_PATH, QUICKDRAW100_LABEL_MAP_PATH)
130
+
131
+
132
+ def load_predictor(model_path: Path, label_map_path: Path) -> tuple[nn.Module, list[str], torch.device, int]:
133
+ if not model_path.exists():
134
+ raise FileNotFoundError(f"Missing CNN model: {model_path}")
135
+ label_map = load_label_map(label_map_path)
136
+ labels = labels_from_map(label_map)
137
+ device = pick_cnn_device()
138
+
139
+ checkpoint = torch.load(model_path, map_location=device, weights_only=False)
140
+ config = checkpoint.get("config", {})
141
+ image_size = int(config.get("image_size", 96))
142
+ model = build_model(
143
+ config.get("model", "resnet18"),
144
+ num_classes=len(labels),
145
+ pretrained=bool(config.get("pretrained", False)),
146
+ freeze_backbone=bool(config.get("freeze_backbone", False)),
147
+ ).to(device)
148
+ model.load_state_dict(checkpoint["model_state"])
149
+ model.eval()
150
+ return model, labels, device, image_size
151
+
152
+
153
+ def decode_data_url(data_url: str) -> bytes:
154
+ if "," in data_url:
155
+ _, encoded = data_url.split(",", 1)
156
+ else:
157
+ encoded = data_url
158
+ try:
159
+ return base64.b64decode(encoded, validate=True)
160
+ except ValueError as exc:
161
+ raise HTTPException(status_code=400, detail="image must be a base64 PNG data URL.") from exc
162
+
163
+
164
+ def uploaded_image_to_tensor(image_bytes: bytes, image_size: int) -> torch.Tensor:
165
+ try:
166
+ image = Image.open(BytesIO(image_bytes)).convert("L")
167
+ except UnidentifiedImageError as exc:
168
+ raise HTTPException(status_code=400, detail="Unsupported image file. Use PNG, JPG, or WEBP.") from exc
169
+
170
+ array = np.asarray(image, dtype=np.float32) / 255.0
171
+ if array.mean() > 0.5:
172
+ array = 1.0 - array
173
+
174
+ ink = array > 0.15
175
+ if not np.any(ink):
176
+ raise HTTPException(status_code=400, detail="No visible sketch stroke detected in the image.")
177
+
178
+ ys, xs = np.where(ink)
179
+ y0, y1 = int(ys.min()), int(ys.max()) + 1
180
+ x0, x1 = int(xs.min()), int(xs.max()) + 1
181
+ crop = array[y0:y1, x0:x1]
182
+
183
+ side = max(crop.shape)
184
+ pad = max(2, int(side * 0.12))
185
+ canvas = np.zeros((side + pad * 2, side + pad * 2), dtype=np.float32)
186
+ offset_y = (canvas.shape[0] - crop.shape[0]) // 2
187
+ offset_x = (canvas.shape[1] - crop.shape[1]) // 2
188
+ canvas[offset_y : offset_y + crop.shape[0], offset_x : offset_x + crop.shape[1]] = crop
189
+
190
+ resample = Image.Resampling.BILINEAR if hasattr(Image, "Resampling") else Image.BILINEAR
191
+ resized = Image.fromarray(np.uint8(np.clip(canvas, 0.0, 1.0) * 255)).resize(
192
+ (image_size, image_size),
193
+ resample,
194
+ )
195
+ tensor_array = np.asarray(resized, dtype=np.float32) / 255.0
196
+ return torch.from_numpy(tensor_array[None, :, :]).unsqueeze(0)
197
+
198
+
199
+ def tensor_image_to_input(image: torch.Tensor, image_size: int) -> torch.Tensor:
200
+ if image.ndim == 2:
201
+ image = image[None, :, :]
202
+ if image.ndim == 3:
203
+ image = image.unsqueeze(0)
204
+ if image.shape[1] != 1:
205
+ image = image.mean(dim=1, keepdim=True)
206
+ image = image.float().clamp(0, 1)
207
+ if image.shape[-2:] != (image_size, image_size):
208
+ image = F.interpolate(image, size=(image_size, image_size), mode="bilinear", align_corners=False)
209
+ return image
210
+
211
+
212
+ def top_predictions(probs: torch.Tensor, labels: list[str], top_k: int) -> list[dict[str, Any]]:
213
+ top_k = max(1, min(int(top_k), len(labels)))
214
+ scores, indices = torch.topk(probs, k=top_k)
215
+ predictions = []
216
+ for score, idx in zip(scores.tolist(), indices.tolist()):
217
+ label = labels[idx]
218
+ predictions.append(
219
+ {
220
+ "label": label,
221
+ "confidence": float(score),
222
+ "reference": f"/api/reference/{label}.svg",
223
+ }
224
+ )
225
+ return predictions
226
+
227
+
228
+ def classes(predictor: str = "legacy20") -> list[str]:
229
+ if predictor == "quickdraw100":
230
+ return labels_from_map(load_label_map(QUICKDRAW100_LABEL_MAP_PATH))
231
+ return labels_from_map(load_label_map(LABEL_MAP_PATH))
232
+
233
+
234
+ def get_predictor(predictor: str) -> tuple[nn.Module, list[str], torch.device, int]:
235
+ if predictor == "quickdraw100":
236
+ return quickdraw100_predictor()
237
+ return image_predictor()
238
+
239
+
240
+ def predict_tensor(image: torch.Tensor, top_k: int = 5, predictor: str = "legacy20") -> dict[str, Any]:
241
+ model, labels, device, image_size = get_predictor(predictor)
242
+ tensor = tensor_image_to_input(image, image_size=image_size).to(device)
243
+ with torch.no_grad():
244
+ logits = model(tensor)
245
+ probs = torch.softmax(logits, dim=1).squeeze(0).detach().cpu()
246
+ predictions = top_predictions(probs, labels, top_k)
247
+ return {
248
+ "model": "cnn",
249
+ "predictor": predictor,
250
+ "input": "tensor",
251
+ "image_size": image_size,
252
+ "prediction": predictions[0],
253
+ "top": predictions,
254
+ }
255
+
256
+
257
+ def predict_image(image_bytes: bytes, top_k: int = 5, predictor: str = "legacy20") -> dict[str, Any]:
258
+ model, labels, device, image_size = get_predictor(predictor)
259
+ tensor = uploaded_image_to_tensor(image_bytes, image_size=image_size).to(device)
260
+ with torch.no_grad():
261
+ logits = model(tensor)
262
+ probs = torch.softmax(logits, dim=1).squeeze(0).detach().cpu()
263
+ predictions = top_predictions(probs, labels, top_k)
264
+ return {
265
+ "model": "cnn",
266
+ "predictor": predictor,
267
+ "input": "image",
268
+ "image_size": image_size,
269
+ "prediction": predictions[0],
270
+ "top": predictions,
271
+ }
quickdraw_app/models/cnn_residual_100cls_64/best_model.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:64b5a16a2736471c43c11eaba631a7433791dd4154fc3e63ea01142d99600170
3
+ size 10962357
quickdraw_app/models/cnn_residual_100cls_64/label_map.json ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "aircraft carrier": 0,
3
+ "airplane": 1,
4
+ "alarm clock": 2,
5
+ "ambulance": 3,
6
+ "angel": 4,
7
+ "animal migration": 5,
8
+ "ant": 6,
9
+ "anvil": 7,
10
+ "apple": 8,
11
+ "arm": 9,
12
+ "asparagus": 10,
13
+ "axe": 11,
14
+ "backpack": 12,
15
+ "banana": 13,
16
+ "bandage": 14,
17
+ "barn": 15,
18
+ "baseball": 16,
19
+ "baseball bat": 17,
20
+ "basket": 18,
21
+ "basketball": 19,
22
+ "bat": 20,
23
+ "bathtub": 21,
24
+ "beach": 22,
25
+ "bear": 23,
26
+ "beard": 24,
27
+ "bed": 25,
28
+ "bee": 26,
29
+ "belt": 27,
30
+ "bench": 28,
31
+ "bicycle": 29,
32
+ "binoculars": 30,
33
+ "bird": 31,
34
+ "birthday cake": 32,
35
+ "blackberry": 33,
36
+ "blueberry": 34,
37
+ "book": 35,
38
+ "boomerang": 36,
39
+ "bottlecap": 37,
40
+ "bowtie": 38,
41
+ "bracelet": 39,
42
+ "brain": 40,
43
+ "bread": 41,
44
+ "bridge": 42,
45
+ "broccoli": 43,
46
+ "broom": 44,
47
+ "bucket": 45,
48
+ "bulldozer": 46,
49
+ "bus": 47,
50
+ "bush": 48,
51
+ "butterfly": 49,
52
+ "cactus": 50,
53
+ "cake": 51,
54
+ "calculator": 52,
55
+ "calendar": 53,
56
+ "camel": 54,
57
+ "camera": 55,
58
+ "camouflage": 56,
59
+ "campfire": 57,
60
+ "candle": 58,
61
+ "cannon": 59,
62
+ "canoe": 60,
63
+ "car": 61,
64
+ "carrot": 62,
65
+ "castle": 63,
66
+ "cat": 64,
67
+ "ceiling fan": 65,
68
+ "cello": 66,
69
+ "cell phone": 67,
70
+ "chair": 68,
71
+ "chandelier": 69,
72
+ "church": 70,
73
+ "circle": 71,
74
+ "clarinet": 72,
75
+ "clock": 73,
76
+ "cloud": 74,
77
+ "coffee cup": 75,
78
+ "compass": 76,
79
+ "computer": 77,
80
+ "cookie": 78,
81
+ "cooler": 79,
82
+ "couch": 80,
83
+ "cow": 81,
84
+ "crab": 82,
85
+ "crayon": 83,
86
+ "crocodile": 84,
87
+ "crown": 85,
88
+ "cruise ship": 86,
89
+ "cup": 87,
90
+ "diamond": 88,
91
+ "dishwasher": 89,
92
+ "diving board": 90,
93
+ "dog": 91,
94
+ "dolphin": 92,
95
+ "donut": 93,
96
+ "door": 94,
97
+ "dragon": 95,
98
+ "dresser": 96,
99
+ "drill": 97,
100
+ "drums": 98,
101
+ "duck": 99
102
+ }
quickdraw_app/server.py ADDED
@@ -0,0 +1,192 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Local web API for sampling the QuickDraw conditional DDPM."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import base64
7
+ import io
8
+ import os
9
+ import random
10
+ import sys
11
+ import threading
12
+ from pathlib import Path
13
+
14
+ import torch
15
+ from fastapi import FastAPI, HTTPException
16
+ from fastapi.responses import FileResponse
17
+ from fastapi.staticfiles import StaticFiles
18
+ from pydantic import BaseModel, Field
19
+ from torchvision.utils import make_grid
20
+
21
+ ROOT = Path(__file__).resolve().parents[1]
22
+ if str(ROOT) not in sys.path:
23
+ sys.path.insert(0, str(ROOT))
24
+
25
+ from train_quickdraw_ddpm import ( # noqa: E402
26
+ QUICKDRAW_100_CLASSES,
27
+ SmallConditionalUNet,
28
+ make_schedule,
29
+ pick_device,
30
+ sample,
31
+ )
32
+
33
+ from quickdraw_app import cnn_classifier # noqa: E402
34
+
35
+
36
+ DEFAULT_CHECKPOINT = ROOT / "models" / "diffusion" / "checkpoint_step_500000.pt"
37
+
38
+ CHECKPOINT_PATH = Path(os.environ.get("QUICKDRAW_CHECKPOINT", DEFAULT_CHECKPOINT)).expanduser()
39
+ STATIC_DIR = Path(__file__).resolve().parent / "static"
40
+
41
+
42
+ class GenerateRequest(BaseModel):
43
+ class_name: str = Field(..., min_length=1)
44
+ count: int = Field(4, ge=1, le=8)
45
+ guidance_scale: float = Field(3.0, ge=0.0, le=8.0)
46
+ seed: int | None = Field(None, ge=0, le=2**31 - 1)
47
+
48
+
49
+ class RecognizeRequest(BaseModel):
50
+ image: str = Field(..., min_length=1)
51
+ top_k: int = Field(5, ge=1, le=10)
52
+ predictor: str = Field("quickdraw100")
53
+
54
+
55
+ class ModelState:
56
+ def __init__(self) -> None:
57
+ self.lock = threading.Lock()
58
+ self.loaded = False
59
+ self.device = pick_device()
60
+ self.model: SmallConditionalUNet | None = None
61
+ self.schedule = None
62
+ self.classes: list[str] = QUICKDRAW_100_CLASSES
63
+ self.image_size = 64
64
+ self.timesteps = 200
65
+ self.base_channels = 64
66
+ self.step: int | None = None
67
+
68
+ def load(self) -> None:
69
+ if self.loaded:
70
+ return
71
+ if not CHECKPOINT_PATH.exists():
72
+ raise FileNotFoundError(f"Checkpoint not found: {CHECKPOINT_PATH}")
73
+
74
+ checkpoint = torch.load(CHECKPOINT_PATH, map_location=self.device, weights_only=False)
75
+ self.classes = list(checkpoint.get("classes", QUICKDRAW_100_CLASSES))
76
+ self.image_size = int(checkpoint.get("image_size", 64))
77
+ self.timesteps = int(checkpoint.get("timesteps", 200))
78
+ self.base_channels = int(checkpoint.get("base_channels", 64))
79
+ self.step = checkpoint.get("step")
80
+
81
+ model = SmallConditionalUNet(len(self.classes), base_channels=self.base_channels).to(self.device)
82
+ state_dict = checkpoint.get("model_unwrapped") or checkpoint.get("model")
83
+ if state_dict is None:
84
+ raise RuntimeError("Checkpoint does not contain a model state dict.")
85
+ model.load_state_dict(state_dict)
86
+ model.eval()
87
+
88
+ self.model = model
89
+ self.schedule = make_schedule(self.timesteps, self.device)
90
+ self.loaded = True
91
+
92
+ def generate(self, request: GenerateRequest) -> str:
93
+ with self.lock:
94
+ self.load()
95
+ assert self.model is not None
96
+ assert self.schedule is not None
97
+
98
+ try:
99
+ class_index = self.classes.index(request.class_name)
100
+ except ValueError as exc:
101
+ raise HTTPException(status_code=400, detail="Unknown class name.") from exc
102
+
103
+ seed = request.seed if request.seed is not None else random.randrange(0, 2**31)
104
+ torch.manual_seed(seed)
105
+ if self.device.type == "cuda":
106
+ torch.cuda.manual_seed_all(seed)
107
+
108
+ labels = torch.full((request.count,), class_index, dtype=torch.long, device=self.device)
109
+ images = sample(
110
+ self.model,
111
+ labels,
112
+ self.image_size,
113
+ self.schedule,
114
+ self.timesteps,
115
+ self.device,
116
+ guidance_scale=request.guidance_scale,
117
+ )
118
+ grid = make_grid((images + 1) / 2, nrow=min(request.count, 4), padding=8, pad_value=0)
119
+ image = grid.mul(255).byte().permute(1, 2, 0).cpu().numpy()
120
+
121
+ from PIL import Image
122
+
123
+ buffer = io.BytesIO()
124
+ Image.fromarray(image).save(buffer, format="PNG")
125
+ encoded = base64.b64encode(buffer.getvalue()).decode("ascii")
126
+ return f"data:image/png;base64,{encoded}"
127
+
128
+
129
+ state = ModelState()
130
+ app = FastAPI(title="QuickDraw Diffusion")
131
+ app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
132
+
133
+
134
+ @app.get("/")
135
+ def index() -> FileResponse:
136
+ return FileResponse(STATIC_DIR / "index.html")
137
+
138
+
139
+ @app.get("/api/status")
140
+ def status() -> dict:
141
+ loaded = state.loaded
142
+ return {
143
+ "loaded": loaded,
144
+ "checkpoint": str(CHECKPOINT_PATH),
145
+ "checkpoint_exists": CHECKPOINT_PATH.exists(),
146
+ "device": str(state.device),
147
+ "step": state.step,
148
+ "image_size": state.image_size,
149
+ "timesteps": state.timesteps,
150
+ "cnn_model": str(cnn_classifier.MODEL_PATH),
151
+ "cnn_model_exists": cnn_classifier.MODEL_PATH.exists(),
152
+ "quickdraw100_cnn_model": str(cnn_classifier.QUICKDRAW100_MODEL_PATH),
153
+ "quickdraw100_cnn_model_exists": cnn_classifier.QUICKDRAW100_MODEL_PATH.exists(),
154
+ }
155
+
156
+
157
+ @app.get("/api/classes")
158
+ def classes() -> dict:
159
+ return {"classes": state.classes}
160
+
161
+
162
+ @app.get("/api/cnn/classes")
163
+ def cnn_classes(predictor: str = "quickdraw100") -> dict:
164
+ try:
165
+ return {"classes": cnn_classifier.classes(predictor=predictor), "predictor": predictor}
166
+ except FileNotFoundError as exc:
167
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
168
+
169
+
170
+ @app.post("/api/generate")
171
+ def generate(request: GenerateRequest) -> dict:
172
+ try:
173
+ image = state.generate(request)
174
+ except FileNotFoundError as exc:
175
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
176
+ return {
177
+ "image": image,
178
+ "class_name": request.class_name,
179
+ "count": request.count,
180
+ "guidance_scale": request.guidance_scale,
181
+ "step": state.step,
182
+ "device": str(state.device),
183
+ }
184
+
185
+
186
+ @app.post("/api/recognize")
187
+ def recognize(request: RecognizeRequest) -> dict:
188
+ image = cnn_classifier.decode_data_url(request.image)
189
+ try:
190
+ return cnn_classifier.predict_image(image, request.top_k, predictor=request.predictor)
191
+ except FileNotFoundError as exc:
192
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
quickdraw_app/static/app.js ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const classSelect = document.querySelector("#class-select");
2
+ const form = document.querySelector("#generate-form");
3
+ const guidance = document.querySelector("#guidance");
4
+ const guidanceValue = document.querySelector("#guidance-value");
5
+ const statusEl = document.querySelector("#status");
6
+ const imageStage = document.querySelector("#image-stage");
7
+ const resultTitle = document.querySelector("#result-title");
8
+ const message = document.querySelector("#message");
9
+ const button = document.querySelector("#generate-button");
10
+ const downloadLink = document.querySelector("#download-link");
11
+ const recognizeButton = document.querySelector("#recognize-button");
12
+ const recognitionList = document.querySelector("#recognition-list");
13
+ const cnnStatus = document.querySelector("#cnn-status");
14
+
15
+ let currentImage = null;
16
+
17
+ function setMessage(text, isError = false) {
18
+ message.textContent = text;
19
+ message.classList.toggle("error", isError);
20
+ }
21
+
22
+ function setLoading(isLoading) {
23
+ button.disabled = isLoading;
24
+ button.querySelector("span:last-child").textContent = isLoading ? "生成中" : "生成";
25
+ }
26
+
27
+ async function loadStatus() {
28
+ const response = await fetch("/api/status");
29
+ const status = await response.json();
30
+ statusEl.textContent = status.checkpoint_exists
31
+ ? `${status.device} · ${status.image_size}x${status.image_size} · step ${status.step ?? "not loaded"}`
32
+ : "checkpoint missing";
33
+ }
34
+
35
+ async function loadCnnStatus() {
36
+ try {
37
+ const response = await fetch("/api/cnn/classes");
38
+ const data = await response.json();
39
+ if (!response.ok) {
40
+ throw new Error(data.detail || "CNN offline");
41
+ }
42
+ const classes = Array.isArray(data) ? data : data.classes;
43
+ cnnStatus.textContent = `CNN: ${classes.length} classes`;
44
+ cnnStatus.title = classes.join(", ");
45
+ } catch (error) {
46
+ cnnStatus.textContent = "CNN: offline";
47
+ cnnStatus.title = error.message;
48
+ }
49
+ }
50
+
51
+ async function loadClasses() {
52
+ const response = await fetch("/api/classes");
53
+ const data = await response.json();
54
+ classSelect.innerHTML = "";
55
+ for (const name of data.classes) {
56
+ const option = document.createElement("option");
57
+ option.value = name;
58
+ option.textContent = name;
59
+ classSelect.append(option);
60
+ }
61
+ classSelect.value = data.classes.includes("cat") ? "cat" : data.classes[0];
62
+ }
63
+
64
+ guidance.addEventListener("input", () => {
65
+ guidanceValue.value = Number(guidance.value).toFixed(2);
66
+ });
67
+
68
+ form.addEventListener("submit", async (event) => {
69
+ event.preventDefault();
70
+ const formData = new FormData(form);
71
+ const seedValue = formData.get("seed");
72
+ const payload = {
73
+ class_name: formData.get("class_name"),
74
+ count: Number(formData.get("count")),
75
+ guidance_scale: Number(formData.get("guidance_scale")),
76
+ seed: seedValue ? Number(seedValue) : null,
77
+ };
78
+
79
+ setLoading(true);
80
+ setMessage("模型采样大约需要几十秒,MPS/CPU 会更慢。");
81
+
82
+ try {
83
+ const response = await fetch("/api/generate", {
84
+ method: "POST",
85
+ headers: { "Content-Type": "application/json" },
86
+ body: JSON.stringify(payload),
87
+ });
88
+ const data = await response.json();
89
+ if (!response.ok) {
90
+ throw new Error(data.detail || "生成失败");
91
+ }
92
+
93
+ imageStage.innerHTML = "";
94
+ const image = document.createElement("img");
95
+ image.src = data.image;
96
+ image.alt = `${data.class_name} generated sketch samples`;
97
+ imageStage.append(image);
98
+ currentImage = data.image;
99
+ resultTitle.textContent = data.class_name;
100
+ downloadLink.href = data.image;
101
+ downloadLink.setAttribute("aria-disabled", "false");
102
+ recognizeButton.disabled = false;
103
+ recognitionList.innerHTML = "";
104
+ setMessage(`完成 · ${data.device} · step ${data.step} · CFG ${data.guidance_scale}`);
105
+ await loadStatus();
106
+ } catch (error) {
107
+ setMessage(error.message, true);
108
+ } finally {
109
+ setLoading(false);
110
+ }
111
+ });
112
+
113
+ recognizeButton.addEventListener("click", async () => {
114
+ if (!currentImage) {
115
+ return;
116
+ }
117
+
118
+ recognizeButton.disabled = true;
119
+ recognizeButton.textContent = "识别中";
120
+ setMessage("正在请求 CNN 图片识别服务。");
121
+
122
+ try {
123
+ const response = await fetch("/api/recognize", {
124
+ method: "POST",
125
+ headers: { "Content-Type": "application/json" },
126
+ body: JSON.stringify({ image: currentImage, top_k: 5, predictor: "quickdraw100" }),
127
+ });
128
+ const data = await response.json();
129
+ if (!response.ok) {
130
+ throw new Error(data.detail || "识别失败");
131
+ }
132
+
133
+ recognitionList.innerHTML = "";
134
+ for (const item of data.top || []) {
135
+ const row = document.createElement("div");
136
+ row.className = "recognition-item";
137
+
138
+ const label = document.createElement("strong");
139
+ label.textContent = item.label;
140
+
141
+ const track = document.createElement("div");
142
+ track.className = "confidence-track";
143
+ const fill = document.createElement("div");
144
+ fill.className = "confidence-fill";
145
+ fill.style.width = `${Math.max(0, Math.min(1, item.confidence)) * 100}%`;
146
+ track.append(fill);
147
+
148
+ const value = document.createElement("span");
149
+ value.className = "confidence-value";
150
+ value.textContent = `${Math.round(item.confidence * 100)}%`;
151
+
152
+ row.append(label, track, value);
153
+ recognitionList.append(row);
154
+ }
155
+ const top = data.prediction;
156
+ setMessage(top ? `CNN top-1: ${top.label} · ${(top.confidence * 100).toFixed(1)}%` : "CNN 返回为空");
157
+ } catch (error) {
158
+ setMessage(error.message, true);
159
+ } finally {
160
+ recognizeButton.disabled = false;
161
+ recognizeButton.textContent = "识别当前图片";
162
+ }
163
+ });
164
+
165
+ Promise.all([loadStatus(), loadClasses(), loadCnnStatus()]).catch((error) => {
166
+ setMessage(error.message, true);
167
+ });
quickdraw_app/static/index.html ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>QuickDraw Diffusion</title>
7
+ <link rel="stylesheet" href="/static/styles.css" />
8
+ </head>
9
+ <body>
10
+ <main class="app-shell">
11
+ <section class="workspace" aria-labelledby="title">
12
+ <div class="toolbar">
13
+ <div>
14
+ <p class="eyebrow">QuickDraw Diffusion</p>
15
+ <h1 id="title">Sketch Generator</h1>
16
+ </div>
17
+ <div class="status" id="status">Checking model...</div>
18
+ </div>
19
+
20
+ <div class="layout">
21
+ <form class="panel controls" id="generate-form">
22
+ <label class="field">
23
+ <span>类别</span>
24
+ <select id="class-select" name="class_name" required></select>
25
+ </label>
26
+
27
+ <div class="field">
28
+ <span>数量</span>
29
+ <div class="segmented" role="radiogroup" aria-label="生成数量">
30
+ <label><input type="radio" name="count" value="1" />1</label>
31
+ <label><input type="radio" name="count" value="4" checked />4</label>
32
+ <label><input type="radio" name="count" value="8" />8</label>
33
+ </div>
34
+ </div>
35
+
36
+ <label class="field">
37
+ <span>CFG</span>
38
+ <div class="range-row">
39
+ <input id="guidance" name="guidance_scale" type="range" min="0" max="8" step="0.25" value="3" />
40
+ <output id="guidance-value" for="guidance">3.00</output>
41
+ </div>
42
+ </label>
43
+
44
+ <label class="field">
45
+ <span>Seed</span>
46
+ <input id="seed" name="seed" type="number" min="0" max="2147483647" placeholder="随机" />
47
+ </label>
48
+
49
+ <button class="generate-button" type="submit" id="generate-button">
50
+ <span class="button-icon" aria-hidden="true"></span>
51
+ <span>生成</span>
52
+ </button>
53
+ </form>
54
+
55
+ <section class="panel preview" aria-live="polite">
56
+ <div class="preview-header">
57
+ <div>
58
+ <p class="eyebrow">Result</p>
59
+ <h2 id="result-title">选择类别后生成</h2>
60
+ </div>
61
+ <a id="download-link" class="download-link" href="#" download="quickdraw-sample.png" aria-disabled="true">下载</a>
62
+ </div>
63
+ <div class="image-stage" id="image-stage">
64
+ <div class="empty-state">等待生成</div>
65
+ </div>
66
+ <div class="recognition">
67
+ <div class="recognition-actions">
68
+ <button class="recognize-button" type="button" id="recognize-button" disabled>识别当前图片</button>
69
+ <span id="cnn-status">CNN: checking...</span>
70
+ </div>
71
+ <div class="recognition-list" id="recognition-list"></div>
72
+ </div>
73
+ <p class="message" id="message"></p>
74
+ </section>
75
+ </div>
76
+ </section>
77
+ </main>
78
+
79
+ <script src="/static/app.js"></script>
80
+ </body>
81
+ </html>
quickdraw_app/static/styles.css ADDED
@@ -0,0 +1,386 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ color-scheme: light;
3
+ --bg: #f6f7f4;
4
+ --surface: #ffffff;
5
+ --surface-soft: #eef1ea;
6
+ --ink: #18201d;
7
+ --muted: #66726d;
8
+ --line: #d8ded6;
9
+ --primary: #16615a;
10
+ --primary-strong: #0d443f;
11
+ --accent: #b7472a;
12
+ --shadow: 0 18px 50px rgba(31, 43, 37, 0.12);
13
+ }
14
+
15
+ * {
16
+ box-sizing: border-box;
17
+ }
18
+
19
+ body {
20
+ margin: 0;
21
+ min-height: 100dvh;
22
+ background: var(--bg);
23
+ color: var(--ink);
24
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
25
+ letter-spacing: 0;
26
+ }
27
+
28
+ button,
29
+ input,
30
+ select {
31
+ font: inherit;
32
+ }
33
+
34
+ .app-shell {
35
+ min-height: 100dvh;
36
+ padding: 28px;
37
+ }
38
+
39
+ .workspace {
40
+ max-width: 1180px;
41
+ margin: 0 auto;
42
+ }
43
+
44
+ .toolbar {
45
+ display: flex;
46
+ align-items: end;
47
+ justify-content: space-between;
48
+ gap: 20px;
49
+ margin-bottom: 20px;
50
+ }
51
+
52
+ .eyebrow {
53
+ margin: 0 0 6px;
54
+ color: var(--accent);
55
+ font-size: 12px;
56
+ font-weight: 700;
57
+ text-transform: uppercase;
58
+ }
59
+
60
+ h1,
61
+ h2 {
62
+ margin: 0;
63
+ line-height: 1.1;
64
+ }
65
+
66
+ h1 {
67
+ font-size: clamp(32px, 5vw, 56px);
68
+ }
69
+
70
+ h2 {
71
+ font-size: 24px;
72
+ }
73
+
74
+ .status {
75
+ min-height: 38px;
76
+ padding: 9px 13px;
77
+ border: 1px solid var(--line);
78
+ border-radius: 8px;
79
+ background: var(--surface);
80
+ color: var(--muted);
81
+ font-size: 14px;
82
+ }
83
+
84
+ .layout {
85
+ display: grid;
86
+ grid-template-columns: 340px minmax(0, 1fr);
87
+ gap: 18px;
88
+ align-items: start;
89
+ }
90
+
91
+ .panel {
92
+ border: 1px solid var(--line);
93
+ border-radius: 8px;
94
+ background: var(--surface);
95
+ box-shadow: var(--shadow);
96
+ }
97
+
98
+ .controls {
99
+ display: grid;
100
+ gap: 18px;
101
+ padding: 18px;
102
+ }
103
+
104
+ .field {
105
+ display: grid;
106
+ gap: 8px;
107
+ color: var(--muted);
108
+ font-size: 14px;
109
+ font-weight: 700;
110
+ }
111
+
112
+ select,
113
+ input[type="number"] {
114
+ width: 100%;
115
+ min-height: 46px;
116
+ border: 1px solid var(--line);
117
+ border-radius: 8px;
118
+ background: var(--surface);
119
+ color: var(--ink);
120
+ padding: 0 12px;
121
+ }
122
+
123
+ select:focus,
124
+ input:focus,
125
+ button:focus-visible,
126
+ a:focus-visible {
127
+ outline: 3px solid rgba(22, 97, 90, 0.22);
128
+ outline-offset: 2px;
129
+ }
130
+
131
+ .segmented {
132
+ display: grid;
133
+ grid-template-columns: repeat(3, 1fr);
134
+ gap: 6px;
135
+ padding: 4px;
136
+ border: 1px solid var(--line);
137
+ border-radius: 8px;
138
+ background: var(--surface-soft);
139
+ }
140
+
141
+ .segmented label {
142
+ min-height: 40px;
143
+ display: grid;
144
+ place-items: center;
145
+ border-radius: 6px;
146
+ color: var(--muted);
147
+ cursor: pointer;
148
+ }
149
+
150
+ .segmented input {
151
+ position: absolute;
152
+ opacity: 0;
153
+ }
154
+
155
+ .segmented label:has(input:checked) {
156
+ background: var(--surface);
157
+ color: var(--ink);
158
+ box-shadow: 0 1px 6px rgba(31, 43, 37, 0.12);
159
+ }
160
+
161
+ .range-row {
162
+ display: grid;
163
+ grid-template-columns: minmax(0, 1fr) 58px;
164
+ gap: 10px;
165
+ align-items: center;
166
+ }
167
+
168
+ input[type="range"] {
169
+ width: 100%;
170
+ accent-color: var(--primary);
171
+ }
172
+
173
+ output {
174
+ color: var(--ink);
175
+ font-variant-numeric: tabular-nums;
176
+ }
177
+
178
+ .generate-button {
179
+ min-height: 48px;
180
+ display: inline-flex;
181
+ align-items: center;
182
+ justify-content: center;
183
+ gap: 10px;
184
+ border: 0;
185
+ border-radius: 8px;
186
+ background: var(--primary);
187
+ color: white;
188
+ font-weight: 800;
189
+ cursor: pointer;
190
+ transition: transform 160ms ease, background 160ms ease, opacity 160ms ease;
191
+ }
192
+
193
+ .generate-button:hover {
194
+ background: var(--primary-strong);
195
+ }
196
+
197
+ .generate-button:active {
198
+ transform: scale(0.98);
199
+ }
200
+
201
+ .generate-button:disabled {
202
+ cursor: wait;
203
+ opacity: 0.64;
204
+ }
205
+
206
+ .recognize-button {
207
+ min-height: 40px;
208
+ display: inline-flex;
209
+ align-items: center;
210
+ justify-content: center;
211
+ border: 1px solid var(--line);
212
+ border-radius: 8px;
213
+ background: var(--surface);
214
+ color: var(--primary);
215
+ padding: 0 14px;
216
+ font-weight: 800;
217
+ cursor: pointer;
218
+ }
219
+
220
+ .recognize-button:disabled {
221
+ cursor: not-allowed;
222
+ color: var(--muted);
223
+ opacity: 0.55;
224
+ }
225
+
226
+ .button-icon {
227
+ width: 16px;
228
+ height: 16px;
229
+ border: 2px solid currentColor;
230
+ border-left-color: transparent;
231
+ border-radius: 50%;
232
+ }
233
+
234
+ .generate-button:not(:disabled) .button-icon {
235
+ border-left-color: currentColor;
236
+ border-radius: 4px;
237
+ }
238
+
239
+ .generate-button:disabled .button-icon {
240
+ animation: spin 800ms linear infinite;
241
+ }
242
+
243
+ .preview {
244
+ min-height: 650px;
245
+ padding: 18px;
246
+ }
247
+
248
+ .preview-header {
249
+ display: flex;
250
+ align-items: center;
251
+ justify-content: space-between;
252
+ gap: 16px;
253
+ margin-bottom: 16px;
254
+ }
255
+
256
+ .download-link {
257
+ min-height: 40px;
258
+ display: inline-flex;
259
+ align-items: center;
260
+ justify-content: center;
261
+ padding: 0 14px;
262
+ border: 1px solid var(--line);
263
+ border-radius: 8px;
264
+ color: var(--primary);
265
+ text-decoration: none;
266
+ font-weight: 800;
267
+ }
268
+
269
+ .download-link[aria-disabled="true"] {
270
+ pointer-events: none;
271
+ color: var(--muted);
272
+ opacity: 0.5;
273
+ }
274
+
275
+ .image-stage {
276
+ min-height: 540px;
277
+ display: grid;
278
+ place-items: center;
279
+ border: 1px solid #151916;
280
+ border-radius: 8px;
281
+ background: #050706;
282
+ overflow: hidden;
283
+ }
284
+
285
+ .image-stage img {
286
+ max-width: 100%;
287
+ height: auto;
288
+ image-rendering: pixelated;
289
+ }
290
+
291
+ .empty-state {
292
+ color: #b9c5bf;
293
+ font-weight: 700;
294
+ }
295
+
296
+ .message {
297
+ min-height: 24px;
298
+ margin: 12px 0 0;
299
+ color: var(--muted);
300
+ font-size: 14px;
301
+ }
302
+
303
+ .recognition {
304
+ display: grid;
305
+ gap: 10px;
306
+ margin-top: 14px;
307
+ }
308
+
309
+ .recognition-actions {
310
+ display: flex;
311
+ align-items: center;
312
+ justify-content: space-between;
313
+ gap: 12px;
314
+ color: var(--muted);
315
+ font-size: 14px;
316
+ }
317
+
318
+ .recognition-list {
319
+ display: grid;
320
+ gap: 8px;
321
+ }
322
+
323
+ .recognition-item {
324
+ display: grid;
325
+ grid-template-columns: 110px minmax(0, 1fr) 56px;
326
+ align-items: center;
327
+ gap: 10px;
328
+ min-height: 36px;
329
+ color: var(--ink);
330
+ font-size: 14px;
331
+ }
332
+
333
+ .confidence-track {
334
+ height: 8px;
335
+ border-radius: 999px;
336
+ background: var(--surface-soft);
337
+ overflow: hidden;
338
+ }
339
+
340
+ .confidence-fill {
341
+ height: 100%;
342
+ border-radius: inherit;
343
+ background: var(--primary);
344
+ }
345
+
346
+ .confidence-value {
347
+ color: var(--muted);
348
+ font-variant-numeric: tabular-nums;
349
+ text-align: right;
350
+ }
351
+
352
+ .message.error {
353
+ color: #a12a22;
354
+ }
355
+
356
+ @keyframes spin {
357
+ to {
358
+ transform: rotate(360deg);
359
+ }
360
+ }
361
+
362
+ @media (max-width: 820px) {
363
+ .app-shell {
364
+ padding: 16px;
365
+ }
366
+
367
+ .toolbar,
368
+ .preview-header,
369
+ .recognition-actions {
370
+ align-items: stretch;
371
+ flex-direction: column;
372
+ }
373
+
374
+ .recognition-item {
375
+ grid-template-columns: 88px minmax(0, 1fr) 50px;
376
+ }
377
+
378
+ .layout {
379
+ grid-template-columns: 1fr;
380
+ }
381
+
382
+ .preview,
383
+ .image-stage {
384
+ min-height: 420px;
385
+ }
386
+ }
requirements.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ fastapi
2
+ uvicorn
3
+ numpy
4
+ pillow
5
+ torch
6
+ torchvision
7
+ tqdm
train_quickdraw_ddpm.py ADDED
@@ -0,0 +1,434 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Train a small class-conditional DDPM on rasterized QuickDraw sketches."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import math
9
+ import random
10
+ import time
11
+ import urllib.parse
12
+ import urllib.request
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+
16
+ import torch
17
+ import torch.nn as nn
18
+ import torch.nn.functional as F
19
+ from PIL import Image, ImageDraw
20
+ from torch.utils.data import DataLoader, Dataset
21
+ from torchvision.utils import save_image
22
+ from tqdm import tqdm
23
+
24
+
25
+ QUICKDRAW_URL = "https://storage.googleapis.com/quickdraw_dataset/full/simplified/{word}.ndjson"
26
+ QUICKDRAW_100_CLASSES = [
27
+ "aircraft carrier", "airplane", "alarm clock", "ambulance", "angel",
28
+ "animal migration", "ant", "anvil", "apple", "arm", "asparagus", "axe",
29
+ "backpack", "banana", "bandage", "barn", "baseball", "baseball bat",
30
+ "basket", "basketball", "bat", "bathtub", "beach", "bear", "beard",
31
+ "bed", "bee", "belt", "bench", "bicycle", "binoculars", "bird",
32
+ "birthday cake", "blackberry", "blueberry", "book", "boomerang",
33
+ "bottlecap", "bowtie", "bracelet", "brain", "bread", "bridge",
34
+ "broccoli", "broom", "bucket", "bulldozer", "bus", "bush", "butterfly",
35
+ "cactus", "cake", "calculator", "calendar", "camel", "camera",
36
+ "camouflage", "campfire", "candle", "cannon", "canoe", "car", "carrot",
37
+ "castle", "cat", "ceiling fan", "cello", "cell phone", "chair",
38
+ "chandelier", "church", "circle", "clarinet", "clock", "cloud",
39
+ "coffee cup", "compass", "computer", "cookie", "cooler", "couch",
40
+ "cow", "crab", "crayon", "crocodile", "crown", "cruise ship", "cup",
41
+ "diamond", "dishwasher", "diving board", "dog", "dolphin", "donut",
42
+ "door", "dragon", "dresser", "drill", "drums", "duck",
43
+ ]
44
+
45
+
46
+ def unwrap_model(model: nn.Module) -> nn.Module:
47
+ return model.module if isinstance(model, nn.DataParallel) else model
48
+
49
+
50
+ def pick_device() -> torch.device:
51
+ if torch.cuda.is_available():
52
+ return torch.device("cuda")
53
+ if torch.backends.mps.is_available():
54
+ return torch.device("mps")
55
+ return torch.device("cpu")
56
+
57
+
58
+ def render_drawing(drawing: list, image_size: int, line_width: int) -> torch.Tensor:
59
+ image = Image.new("L", (image_size, image_size), 255)
60
+ draw = ImageDraw.Draw(image)
61
+ scale = image_size / 256.0
62
+
63
+ for stroke in drawing:
64
+ xs, ys = stroke
65
+ points = [(round(x * scale), round(y * scale)) for x, y in zip(xs, ys)]
66
+ if len(points) >= 2:
67
+ draw.line(points, fill=0, width=line_width)
68
+ elif len(points) == 1:
69
+ x, y = points[0]
70
+ r = max(1, line_width // 2)
71
+ draw.ellipse((x - r, y - r, x + r, y + r), fill=0)
72
+
73
+ data = torch.tensor(list(image.tobytes()), dtype=torch.uint8).view(1, image_size, image_size)
74
+ return 255 - data
75
+
76
+
77
+ class QuickDrawSketches(Dataset):
78
+ def __init__(
79
+ self,
80
+ classes: list[str],
81
+ samples_per_class: int,
82
+ image_size: int,
83
+ line_width: int,
84
+ recognized_only: bool = True,
85
+ download_retries: int = 5,
86
+ ) -> None:
87
+ self.classes = classes
88
+ total_samples = len(classes) * samples_per_class
89
+ images = torch.empty(total_samples, 1, image_size, image_size, dtype=torch.uint8)
90
+ labels = torch.empty(total_samples, dtype=torch.long)
91
+
92
+ for label, word in enumerate(classes):
93
+ quoted = urllib.parse.quote(word, safe="")
94
+ url = QUICKDRAW_URL.format(word=quoted)
95
+ for attempt in range(1, download_retries + 1):
96
+ loaded = 0
97
+ try:
98
+ with urllib.request.urlopen(url, timeout=60) as response:
99
+ for raw_line in response:
100
+ item = json.loads(raw_line)
101
+ if recognized_only and not item.get("recognized", False):
102
+ continue
103
+ index = label * samples_per_class + loaded
104
+ images[index] = render_drawing(item["drawing"], image_size, line_width)
105
+ labels[index] = label
106
+ loaded += 1
107
+ if loaded >= samples_per_class:
108
+ break
109
+ if loaded >= samples_per_class:
110
+ print(f"loaded class {label + 1}/{len(classes)}: {word} ({loaded})", flush=True)
111
+ break
112
+ raise RuntimeError(f"Only loaded {loaded} samples for class {word!r}")
113
+ except Exception:
114
+ if attempt == download_retries:
115
+ raise
116
+ time.sleep(min(2 ** attempt, 30))
117
+ self.images = images
118
+ self.labels = labels
119
+
120
+ def __len__(self) -> int:
121
+ return self.images.shape[0]
122
+
123
+ def __getitem__(self, index: int) -> tuple[torch.Tensor, torch.Tensor]:
124
+ image = self.images[index].float() / 127.5 - 1.0
125
+ return image, self.labels[index]
126
+
127
+
128
+ class SinusoidalTimeEmbedding(nn.Module):
129
+ def __init__(self, dim: int) -> None:
130
+ super().__init__()
131
+ self.dim = dim
132
+
133
+ def forward(self, t: torch.Tensor) -> torch.Tensor:
134
+ half = self.dim // 2
135
+ freqs = torch.exp(
136
+ -math.log(10000) * torch.arange(half, device=t.device).float() / max(half - 1, 1)
137
+ )
138
+ args = t.float().unsqueeze(1) * freqs.unsqueeze(0)
139
+ emb = torch.cat([args.sin(), args.cos()], dim=1)
140
+ if self.dim % 2:
141
+ emb = F.pad(emb, (0, 1))
142
+ return emb
143
+
144
+
145
+ class ResBlock(nn.Module):
146
+ def __init__(self, in_ch: int, out_ch: int, emb_dim: int) -> None:
147
+ super().__init__()
148
+ self.norm1 = nn.GroupNorm(min(8, in_ch), in_ch)
149
+ self.conv1 = nn.Conv2d(in_ch, out_ch, 3, padding=1)
150
+ self.emb = nn.Linear(emb_dim, out_ch)
151
+ self.norm2 = nn.GroupNorm(min(8, out_ch), out_ch)
152
+ self.conv2 = nn.Conv2d(out_ch, out_ch, 3, padding=1)
153
+ self.skip = nn.Conv2d(in_ch, out_ch, 1) if in_ch != out_ch else nn.Identity()
154
+
155
+ def forward(self, x: torch.Tensor, emb: torch.Tensor) -> torch.Tensor:
156
+ h = self.conv1(F.silu(self.norm1(x)))
157
+ h = h + self.emb(F.silu(emb))[:, :, None, None]
158
+ h = self.conv2(F.silu(self.norm2(h)))
159
+ return h + self.skip(x)
160
+
161
+
162
+ class SmallConditionalUNet(nn.Module):
163
+ def __init__(self, num_classes: int, base_channels: int = 64, emb_dim: int = 256) -> None:
164
+ super().__init__()
165
+ self.num_classes = num_classes
166
+ self.null_label = num_classes
167
+ self.time_mlp = nn.Sequential(
168
+ SinusoidalTimeEmbedding(emb_dim),
169
+ nn.Linear(emb_dim, emb_dim),
170
+ nn.SiLU(),
171
+ nn.Linear(emb_dim, emb_dim),
172
+ )
173
+ self.class_emb = nn.Embedding(num_classes + 1, emb_dim)
174
+
175
+ c = base_channels
176
+ self.in_conv = nn.Conv2d(1, c, 3, padding=1)
177
+ self.down1 = ResBlock(c, c, emb_dim)
178
+ self.downsample1 = nn.Conv2d(c, c * 2, 4, stride=2, padding=1)
179
+ self.down2 = ResBlock(c * 2, c * 2, emb_dim)
180
+ self.downsample2 = nn.Conv2d(c * 2, c * 4, 4, stride=2, padding=1)
181
+ self.mid1 = ResBlock(c * 4, c * 4, emb_dim)
182
+ self.mid2 = ResBlock(c * 4, c * 4, emb_dim)
183
+ self.upsample2 = nn.ConvTranspose2d(c * 4, c * 2, 4, stride=2, padding=1)
184
+ self.up2 = ResBlock(c * 4, c * 2, emb_dim)
185
+ self.upsample1 = nn.ConvTranspose2d(c * 2, c, 4, stride=2, padding=1)
186
+ self.up1 = ResBlock(c * 2, c, emb_dim)
187
+ self.out_norm = nn.GroupNorm(min(8, c), c)
188
+ self.out_conv = nn.Conv2d(c, 1, 3, padding=1)
189
+
190
+ def forward(self, x: torch.Tensor, t: torch.Tensor, y: torch.Tensor) -> torch.Tensor:
191
+ emb = self.time_mlp(t) + self.class_emb(y)
192
+ x0 = self.in_conv(x)
193
+ x1 = self.down1(x0, emb)
194
+ x2 = self.down2(self.downsample1(x1), emb)
195
+ x3 = self.mid2(self.mid1(self.downsample2(x2), emb), emb)
196
+ x = self.upsample2(x3)
197
+ x = self.up2(torch.cat([x, x2], dim=1), emb)
198
+ x = self.upsample1(x)
199
+ x = self.up1(torch.cat([x, x1], dim=1), emb)
200
+ return self.out_conv(F.silu(self.out_norm(x)))
201
+
202
+
203
+ @dataclass
204
+ class DiffusionSchedule:
205
+ betas: torch.Tensor
206
+ alphas: torch.Tensor
207
+ alphas_cumprod: torch.Tensor
208
+ alphas_cumprod_prev: torch.Tensor
209
+ sqrt_alphas_cumprod: torch.Tensor
210
+ sqrt_one_minus_alphas_cumprod: torch.Tensor
211
+ posterior_variance: torch.Tensor
212
+
213
+
214
+ def make_schedule(timesteps: int, device: torch.device) -> DiffusionSchedule:
215
+ steps = timesteps + 1
216
+ x = torch.linspace(0, timesteps, steps, device=device)
217
+ alphas_cumprod = torch.cos(((x / timesteps) + 0.008) / 1.008 * math.pi * 0.5) ** 2
218
+ alphas_cumprod = alphas_cumprod / alphas_cumprod[0]
219
+ betas = 1.0 - (alphas_cumprod[1:] / alphas_cumprod[:-1])
220
+ betas = betas.clamp(1e-4, 0.999)
221
+ alphas = 1.0 - betas
222
+ alphas_cumprod = torch.cumprod(alphas, dim=0)
223
+ alphas_cumprod_prev = F.pad(alphas_cumprod[:-1], (1, 0), value=1.0)
224
+ posterior_variance = betas * (1.0 - alphas_cumprod_prev) / (1.0 - alphas_cumprod)
225
+ return DiffusionSchedule(
226
+ betas=betas,
227
+ alphas=alphas,
228
+ alphas_cumprod=alphas_cumprod,
229
+ alphas_cumprod_prev=alphas_cumprod_prev,
230
+ sqrt_alphas_cumprod=torch.sqrt(alphas_cumprod),
231
+ sqrt_one_minus_alphas_cumprod=torch.sqrt(1.0 - alphas_cumprod),
232
+ posterior_variance=posterior_variance,
233
+ )
234
+
235
+
236
+ def extract(values: torch.Tensor, t: torch.Tensor, x_shape: torch.Size) -> torch.Tensor:
237
+ return values.gather(0, t).view(t.shape[0], *((1,) * (len(x_shape) - 1)))
238
+
239
+
240
+ def q_sample(x0: torch.Tensor, t: torch.Tensor, noise: torch.Tensor, schedule: DiffusionSchedule) -> torch.Tensor:
241
+ return (
242
+ extract(schedule.sqrt_alphas_cumprod, t, x0.shape) * x0
243
+ + extract(schedule.sqrt_one_minus_alphas_cumprod, t, x0.shape) * noise
244
+ )
245
+
246
+
247
+ @torch.no_grad()
248
+ def sample(
249
+ model: nn.Module,
250
+ labels: torch.Tensor,
251
+ image_size: int,
252
+ schedule: DiffusionSchedule,
253
+ timesteps: int,
254
+ device: torch.device,
255
+ guidance_scale: float = 1.0,
256
+ ) -> torch.Tensor:
257
+ model.eval()
258
+ x = torch.randn(labels.shape[0], 1, image_size, image_size, device=device)
259
+ null_labels = torch.full_like(labels, unwrap_model(model).null_label)
260
+ for step in tqdm(reversed(range(timesteps)), total=timesteps, desc="sample"):
261
+ t = torch.full((labels.shape[0],), step, device=device, dtype=torch.long)
262
+ if guidance_scale == 1.0:
263
+ pred_noise = model(x, t, labels)
264
+ else:
265
+ pred_uncond = model(x, t, null_labels)
266
+ pred_cond = model(x, t, labels)
267
+ pred_noise = pred_uncond + guidance_scale * (pred_cond - pred_uncond)
268
+ alpha_bar_t = extract(schedule.alphas_cumprod, t, x.shape)
269
+ alpha_bar_prev = extract(schedule.alphas_cumprod_prev, t, x.shape)
270
+ beta_t = extract(schedule.betas, t, x.shape)
271
+ alpha_t = extract(schedule.alphas, t, x.shape)
272
+ pred_x0 = (x - torch.sqrt(1.0 - alpha_bar_t) * pred_noise) / torch.sqrt(alpha_bar_t)
273
+ pred_x0 = pred_x0.clamp(-1, 1)
274
+ coef_x0 = beta_t * torch.sqrt(alpha_bar_prev) / (1.0 - alpha_bar_t)
275
+ coef_xt = (1.0 - alpha_bar_prev) * torch.sqrt(alpha_t) / (1.0 - alpha_bar_t)
276
+ mean = coef_x0 * pred_x0 + coef_xt * x
277
+ if step > 0:
278
+ variance = extract(schedule.posterior_variance, t, x.shape)
279
+ x = mean + torch.sqrt(variance.clamp_min(1e-20)) * torch.randn_like(x)
280
+ else:
281
+ x = mean
282
+ return x.clamp(-1, 1)
283
+
284
+
285
+ def parse_args() -> argparse.Namespace:
286
+ parser = argparse.ArgumentParser()
287
+ parser.add_argument("--classes", nargs="+", default=["cat", "dog", "house", "airplane"])
288
+ parser.add_argument("--num-classes", type=int, default=0)
289
+ parser.add_argument("--samples-per-class", type=int, default=1000)
290
+ parser.add_argument("--image-size", type=int, default=64)
291
+ parser.add_argument("--line-width", type=int, default=2)
292
+ parser.add_argument("--batch-size", type=int, default=64)
293
+ parser.add_argument("--steps", type=int, default=1000)
294
+ parser.add_argument("--timesteps", type=int, default=200)
295
+ parser.add_argument("--lr", type=float, default=2e-4)
296
+ parser.add_argument("--base-channels", type=int, default=48)
297
+ parser.add_argument("--seed", type=int, default=7)
298
+ parser.add_argument("--out-dir", type=Path, default=Path("runs/quickdraw-ddpm"))
299
+ parser.add_argument("--sample-every", type=int, default=250)
300
+ parser.add_argument("--save-every", type=int, default=500)
301
+ parser.add_argument("--cfg-drop-prob", type=float, default=0.1)
302
+ parser.add_argument("--guidance-scale", type=float, default=3.0)
303
+ parser.add_argument("--download-retries", type=int, default=5)
304
+ parser.add_argument("--data-parallel", action="store_true")
305
+ parser.add_argument("--sample-num-classes", type=int, default=16)
306
+ parser.add_argument("--resume", type=Path, default=None)
307
+ return parser.parse_args()
308
+
309
+
310
+ def main() -> None:
311
+ args = parse_args()
312
+ random.seed(args.seed)
313
+ torch.manual_seed(args.seed)
314
+ if args.num_classes:
315
+ if args.num_classes > len(QUICKDRAW_100_CLASSES):
316
+ raise ValueError(f"--num-classes supports at most {len(QUICKDRAW_100_CLASSES)} built-in classes")
317
+ args.classes = QUICKDRAW_100_CLASSES[: args.num_classes]
318
+ resume_checkpoint = None
319
+ if args.resume is not None:
320
+ resume_checkpoint = torch.load(args.resume, map_location="cpu", weights_only=False)
321
+ args.classes = list(resume_checkpoint["classes"])
322
+ args.image_size = int(resume_checkpoint["image_size"])
323
+ args.timesteps = int(resume_checkpoint["timesteps"])
324
+ args.base_channels = int(resume_checkpoint["base_channels"])
325
+
326
+ run_dir = args.out_dir / time.strftime("%Y%m%d-%H%M%S")
327
+ run_dir.mkdir(parents=True, exist_ok=True)
328
+ device = pick_device()
329
+
330
+ print(f"device: {device}")
331
+ print(f"classes: {args.classes}")
332
+ print("loading and rasterizing QuickDraw samples...")
333
+ dataset = QuickDrawSketches(
334
+ classes=args.classes,
335
+ samples_per_class=args.samples_per_class,
336
+ image_size=args.image_size,
337
+ line_width=args.line_width,
338
+ download_retries=args.download_retries,
339
+ )
340
+ loader = DataLoader(dataset, batch_size=args.batch_size, shuffle=True, drop_last=True)
341
+
342
+ model = SmallConditionalUNet(len(args.classes), base_channels=args.base_channels).to(device)
343
+ if args.data_parallel:
344
+ if device.type != "cuda" or torch.cuda.device_count() < 2:
345
+ raise RuntimeError("--data-parallel requires at least two visible CUDA devices")
346
+ model = nn.DataParallel(model)
347
+ print(f"data_parallel_devices: {torch.cuda.device_count()}")
348
+ schedule = make_schedule(args.timesteps, device)
349
+ opt = torch.optim.AdamW(model.parameters(), lr=args.lr)
350
+ start_step = 0
351
+ if resume_checkpoint is not None:
352
+ state_dict = resume_checkpoint.get("model_unwrapped") or resume_checkpoint["model"]
353
+ unwrap_model(model).load_state_dict(state_dict)
354
+ opt.load_state_dict(resume_checkpoint["optimizer"])
355
+ start_step = int(resume_checkpoint["step"])
356
+ print(f"resumed checkpoint: {args.resume} at step {start_step}", flush=True)
357
+
358
+ with (run_dir / "config.json").open("w") as f:
359
+ json.dump(
360
+ vars(args) | {"device": str(device), "run_dir": str(run_dir), "start_step": start_step},
361
+ f,
362
+ indent=2,
363
+ default=str,
364
+ )
365
+
366
+ data_iter = iter(loader)
367
+ pbar = tqdm(range(start_step + 1, args.steps + 1), desc="train")
368
+ last_loss = None
369
+ for step in pbar:
370
+ try:
371
+ x0, labels = next(data_iter)
372
+ except StopIteration:
373
+ data_iter = iter(loader)
374
+ x0, labels = next(data_iter)
375
+
376
+ x0 = x0.to(device)
377
+ labels = labels.to(device)
378
+ if args.cfg_drop_prob > 0:
379
+ drop_mask = torch.rand(labels.shape, device=device) < args.cfg_drop_prob
380
+ labels_for_model = labels.masked_fill(drop_mask, unwrap_model(model).null_label)
381
+ else:
382
+ labels_for_model = labels
383
+ t = torch.randint(0, args.timesteps, (x0.shape[0],), device=device)
384
+ noise = torch.randn_like(x0)
385
+ xt = q_sample(x0, t, noise, schedule)
386
+ pred_noise = model(xt, t, labels_for_model)
387
+ loss = F.mse_loss(pred_noise, noise)
388
+
389
+ opt.zero_grad(set_to_none=True)
390
+ loss.backward()
391
+ nn.utils.clip_grad_norm_(model.parameters(), 1.0)
392
+ opt.step()
393
+
394
+ last_loss = float(loss.item())
395
+ pbar.set_postfix(loss=f"{last_loss:.4f}")
396
+
397
+ if step % args.sample_every == 0 or step == args.steps:
398
+ sample_class_count = min(args.sample_num_classes, len(args.classes))
399
+ sample_labels = torch.arange(sample_class_count, device=device).repeat_interleave(4)
400
+ images = sample(
401
+ model,
402
+ sample_labels,
403
+ args.image_size,
404
+ schedule,
405
+ args.timesteps,
406
+ device,
407
+ guidance_scale=args.guidance_scale,
408
+ )
409
+ save_image((images + 1) / 2, run_dir / f"samples_step_{step:06d}.png", nrow=4)
410
+ model.train()
411
+
412
+ if step % args.save_every == 0 or step == args.steps:
413
+ torch.save(
414
+ {
415
+ "model": model.state_dict(),
416
+ "model_unwrapped": unwrap_model(model).state_dict(),
417
+ "optimizer": opt.state_dict(),
418
+ "step": step,
419
+ "classes": args.classes,
420
+ "image_size": args.image_size,
421
+ "timesteps": args.timesteps,
422
+ "base_channels": args.base_channels,
423
+ "cfg_drop_prob": args.cfg_drop_prob,
424
+ "guidance_scale": args.guidance_scale,
425
+ "loss": last_loss,
426
+ },
427
+ run_dir / f"checkpoint_step_{step:06d}.pt",
428
+ )
429
+
430
+ print(f"done: {run_dir}")
431
+
432
+
433
+ if __name__ == "__main__":
434
+ main()