Add QuickDraw diffusion model and app code
Browse files- README.md +148 -0
- assets/samples_step_500000.png +0 -0
- models/diffusion/checkpoint_step_500000.pt +3 -0
- quickdraw_app/README.md +25 -0
- quickdraw_app/cnn_classifier.py +271 -0
- quickdraw_app/models/cnn_residual_100cls_64/best_model.pt +3 -0
- quickdraw_app/models/cnn_residual_100cls_64/label_map.json +102 -0
- quickdraw_app/server.py +192 -0
- quickdraw_app/static/app.js +167 -0
- quickdraw_app/static/index.html +81 -0
- quickdraw_app/static/styles.css +386 -0
- requirements.txt +7 -0
- train_quickdraw_ddpm.py +434 -0
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()
|