Yankkee commited on
Commit
ea664ce
·
verified ·
1 Parent(s): e9d20aa

Upload 3 files

Browse files
Files changed (3) hide show
  1. README.md +64 -8
  2. app.py +367 -0
  3. requirements.txt +9 -0
README.md CHANGED
@@ -1,14 +1,70 @@
1
  ---
2
- title: Logo Iteration
3
- emoji: 🔥
4
- colorFrom: purple
5
- colorTo: red
6
  sdk: gradio
7
- sdk_version: 6.22.0
8
- python_version: '3.12'
9
  app_file: app.py
10
  pinned: false
11
- short_description: 20 Logos iterations
 
12
  ---
13
 
14
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Mass Iteration Studio
3
+ emoji: 🌀
4
+ colorFrom: indigo
5
+ colorTo: pink
6
  sdk: gradio
7
+ sdk_version: 6.5.1
 
8
  app_file: app.py
9
  pinned: false
10
+ license: mit
11
+ short_description: One image in, N PNG variants out
12
  ---
13
 
14
+ # Mass Iteration Studio
15
+
16
+ Upload one image, get N variants back as PNG. Color, style and form are sampled
17
+ per variant from editable prompt pools. Requires **ZeroGPU** hardware.
18
+
19
+ > Set `sdk_version` above to the value Hugging Face wrote into the README it
20
+ > generated for your Space.
21
+
22
+ ## Setup
23
+
24
+ 1. New Space → SDK **Gradio** → Settings → Hardware → **ZeroGPU**.
25
+ 2. Upload `app.py`, `requirements.txt`, `README.md`.
26
+ 3. First run downloads ~7 GB of weights and takes several minutes. Every run
27
+ after that starts instantly — the pipeline stays in memory.
28
+
29
+ ## How the run is structured
30
+
31
+ A 20-image run is not one GPU call. Variants are grouped by denoising strength
32
+ and rendered in batches, each batch its own `@spaces.GPU(duration=75)` call.
33
+ That keeps every call well under the platform ceiling and lets results stream
34
+ into the gallery as they finish, instead of appearing all at once at the end.
35
+
36
+ ## Models
37
+
38
+ | Option | Time per image | 20 variants | License |
39
+ |---|---|---|---|
40
+ | SDXL-Lightning 4-step (default) | ~0.8 s | ~16 s | OpenRAIL++ base, Apache 2.0 LoRA |
41
+ | SDXL-Turbo 2-step | ~0.45 s | ~9 s | non-commercial |
42
+ | SD-Turbo 2-step | ~0.2 s | ~4 s | non-commercial |
43
+
44
+ Pro quota is 1500 GPU-seconds per day, so the default model gives roughly 90
45
+ runs of 20 variants before the window resets.
46
+
47
+ ## The reinvention slider
48
+
49
+ One control decides everything about how far variants drift:
50
+
51
+ - **0.2–0.35** — recolor and restyle, subject untouched
52
+ - **0.4–0.55** — forms shift, proportions change, subject still recognizable
53
+ - **0.65–0.85** — only the rough composition survives
54
+
55
+ Each variant draws its own value between your minimum and maximum, snapped to
56
+ six discrete levels so same-level variants can share a batched GPU call.
57
+
58
+ ## Prompt pools
59
+
60
+ Three editable text boxes, one option per line. Sampling is either a random mix
61
+ or a grid sweep that walks every combination in order — useful when you want
62
+ systematic coverage rather than a lucky draw.
63
+
64
+ Every run writes `manifest.csv` into the ZIP with the prompt, strength and seed
65
+ behind each file. To rebuild a single variant exactly, set the base seed and
66
+ turn off randomization.
67
+
68
+ ## Output
69
+
70
+ PNG only. Individual downloads from the gallery, or the full run as a ZIP.
app.py ADDED
@@ -0,0 +1,367 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Mass Iteration Studio — one image in, N variants out (PNG only).
3
+
4
+ Built for ZeroGPU: work is split into short GPU calls so a 100+ image run
5
+ never exceeds the per-call duration limit.
6
+ """
7
+
8
+ import os
9
+ import csv
10
+ import math
11
+ import random
12
+ import tempfile
13
+ import zipfile
14
+ from datetime import datetime
15
+
16
+ import gradio as gr
17
+ import spaces
18
+ import torch
19
+ from PIL import Image
20
+ from diffusers import AutoPipelineForImage2Image, EulerDiscreteScheduler
21
+ from huggingface_hub import hf_hub_download
22
+
23
+ # --------------------------------------------------------------------------
24
+ # Models
25
+ # --------------------------------------------------------------------------
26
+ MODELS = {
27
+ "SDXL-Lightning · 4 steps · 1024px": {
28
+ "base": "stabilityai/stable-diffusion-xl-base-1.0",
29
+ "lora": ("ByteDance/SDXL-Lightning", "sdxl_lightning_4step_lora.safetensors"),
30
+ "steps": 4,
31
+ "guidance": 1.0,
32
+ "size": 1024,
33
+ "trailing": True,
34
+ "sec_per_image": 0.8,
35
+ },
36
+ "SDXL-Turbo · 2 steps · 768px": {
37
+ "base": "stabilityai/sdxl-turbo",
38
+ "lora": None,
39
+ "steps": 2,
40
+ "guidance": 0.0,
41
+ "size": 768,
42
+ "trailing": False,
43
+ "sec_per_image": 0.45,
44
+ },
45
+ "SD-Turbo · 2 steps · 512px": {
46
+ "base": "stabilityai/sd-turbo",
47
+ "lora": None,
48
+ "steps": 2,
49
+ "guidance": 0.0,
50
+ "size": 512,
51
+ "trailing": False,
52
+ "sec_per_image": 0.2,
53
+ },
54
+ }
55
+ DEFAULT_MODEL = "SDXL-Lightning · 4 steps · 1024px"
56
+
57
+ DTYPE = torch.float16
58
+ _pipes = {}
59
+
60
+
61
+ def get_pipe(name: str):
62
+ """Load once, keep in memory. First call downloads several GB."""
63
+ if name in _pipes:
64
+ return _pipes[name]
65
+
66
+ cfg = MODELS[name]
67
+ pipe = AutoPipelineForImage2Image.from_pretrained(
68
+ cfg["base"], torch_dtype=DTYPE, variant="fp16", use_safetensors=True
69
+ )
70
+ if cfg["lora"]:
71
+ repo, ckpt = cfg["lora"]
72
+ pipe.load_lora_weights(hf_hub_download(repo, ckpt))
73
+ pipe.fuse_lora()
74
+ if cfg["trailing"]:
75
+ pipe.scheduler = EulerDiscreteScheduler.from_config(
76
+ pipe.scheduler.config, timestep_spacing="trailing"
77
+ )
78
+ pipe.set_progress_bar_config(disable=True)
79
+ pipe.to("cuda")
80
+ _pipes[name] = pipe
81
+ return pipe
82
+
83
+
84
+ # --------------------------------------------------------------------------
85
+ # Prompt pools — this is where the "mass" in mass iteration comes from
86
+ # --------------------------------------------------------------------------
87
+ POOL_COLOR = """warm terracotta and bone white
88
+ cold teal and graphite
89
+ monochrome charcoal on paper
90
+ acid green on deep black
91
+ dusty lilac and sand
92
+ indigo with brass gold
93
+ faded coral and sea foam
94
+ oxblood red and cream
95
+ electric cyan and magenta
96
+ muted olive and clay"""
97
+
98
+ POOL_STYLE = """flat vector illustration
99
+ risograph print with grain
100
+ thick ink outlines, cel shaded
101
+ soft airbrush gradients
102
+ woodcut engraving
103
+ matte gouache painting
104
+ chrome and glass render
105
+ halftone comic print
106
+ minimal bauhaus poster
107
+ chalk on blackboard"""
108
+
109
+ POOL_SHAPE = """rounded organic shapes
110
+ sharp angular geometry
111
+ elongated slender proportions
112
+ chunky bold silhouettes
113
+ fragmented and shattered forms
114
+ symmetrical and centered
115
+ loose hand drawn contours
116
+ tightly packed dense composition"""
117
+
118
+ NEGATIVE = "blurry, low quality, watermark, text artifacts, jpeg artifacts, deformed"
119
+
120
+
121
+ def as_list(text: str):
122
+ return [line.strip() for line in text.splitlines() if line.strip()]
123
+
124
+
125
+ def build_recipes(n, base_prompt, colors, styles, shapes, strat, seed,
126
+ smin, smax):
127
+ """One recipe per variant: prompt, strength and seed."""
128
+ rng = random.Random(seed)
129
+ pools = [p for p in (colors, styles, shapes) if p]
130
+ combos = []
131
+
132
+ if strat.startswith("Grid") and pools:
133
+ # walk every combination in order, cycle if n exceeds the product
134
+ total = 1
135
+ for p in pools:
136
+ total *= len(p)
137
+ for i in range(n):
138
+ idx, parts = i % total, []
139
+ for p in reversed(pools):
140
+ parts.append(p[idx % len(p)])
141
+ idx //= len(p)
142
+ combos.append(list(reversed(parts)))
143
+ else:
144
+ for _ in range(n):
145
+ combos.append([rng.choice(p) for p in pools])
146
+
147
+ # snap strength onto a few discrete levels — variants that share a level
148
+ # can be denoised in one batched GPU call, which is most of the speed
149
+ levels, step = 6, 0.0
150
+ if smax > smin:
151
+ step = (smax - smin) / (levels - 1)
152
+
153
+ recipes = []
154
+ for i, parts in enumerate(combos):
155
+ bits = ([base_prompt.strip()] if base_prompt.strip() else []) + parts
156
+ if step:
157
+ strength = smin + round((rng.uniform(smin, smax) - smin) / step) * step
158
+ else:
159
+ strength = smin
160
+ recipes.append({
161
+ "index": i + 1,
162
+ "prompt": ", ".join(bits),
163
+ "strength": round(strength, 3),
164
+ "seed": seed + i,
165
+ })
166
+ return recipes
167
+
168
+
169
+ def fit(img: Image.Image, target: int) -> Image.Image:
170
+ """Scale so the long edge hits the model's native size, snapped to /8."""
171
+ img = img.convert("RGB")
172
+ w, h = img.size
173
+ s = target / max(w, h)
174
+ return img.resize((max(64, int(w * s) // 8 * 8),
175
+ max(64, int(h * s) // 8 * 8)), Image.LANCZOS)
176
+
177
+
178
+ # --------------------------------------------------------------------------
179
+ # GPU work — one short call per batch keeps us under the duration ceiling
180
+ # --------------------------------------------------------------------------
181
+ @spaces.GPU(duration=75)
182
+ def render_batch(model_name, image, recipes, negative):
183
+ cfg = MODELS[model_name]
184
+ pipe = get_pipe(model_name)
185
+
186
+ prompts = [r["prompt"] for r in recipes]
187
+ strength = float(recipes[0]["strength"])
188
+ # few-step schedulers need steps*strength >= 1 to denoise at all
189
+ steps = min(14, max(cfg["steps"], math.ceil(cfg["steps"] / max(strength, 0.15))))
190
+ gens = [torch.Generator("cuda").manual_seed(r["seed"]) for r in recipes]
191
+
192
+ out = pipe(
193
+ prompt=prompts,
194
+ negative_prompt=[negative] * len(prompts) if cfg["guidance"] > 0 else None,
195
+ image=[image] * len(prompts),
196
+ strength=strength,
197
+ num_inference_steps=steps,
198
+ guidance_scale=cfg["guidance"],
199
+ generator=gens,
200
+ )
201
+ return out.images
202
+
203
+
204
+ # --------------------------------------------------------------------------
205
+ # Orchestration — runs on CPU, streams results as they land
206
+ # --------------------------------------------------------------------------
207
+ def generate(image, model_name, count, base_prompt, colors_raw, styles_raw,
208
+ shapes_raw, strat, smin, smax, seed, randomize, batch_size,
209
+ negative, progress=gr.Progress()):
210
+
211
+ if image is None:
212
+ raise gr.Error("Upload an image first.")
213
+ if smax < smin:
214
+ smin, smax = smax, smin
215
+
216
+ count = int(count)
217
+ if randomize:
218
+ seed = random.randint(0, 2**31 - 1)
219
+ seed = int(seed)
220
+
221
+ cfg = MODELS[model_name]
222
+ src = fit(image, cfg["size"])
223
+ recipes = build_recipes(
224
+ count, base_prompt, as_list(colors_raw), as_list(styles_raw),
225
+ as_list(shapes_raw), strat, seed, float(smin), float(smax),
226
+ )
227
+
228
+ # group by strength so every batch shares one denoising schedule
229
+ for r in recipes:
230
+ r["strength"] = round(r["strength"], 2)
231
+ recipes.sort(key=lambda r: r["strength"])
232
+
233
+ run_dir = os.path.join(tempfile.gettempdir(),
234
+ f"run_{datetime.now():%H%M%S}_{seed}")
235
+ os.makedirs(run_dir, exist_ok=True)
236
+
237
+ gallery, done = [], 0
238
+ bs = int(batch_size)
239
+ batches = []
240
+ i = 0
241
+ while i < len(recipes):
242
+ chunk = [recipes[i]]
243
+ i += 1
244
+ while i < len(recipes) and len(chunk) < bs and \
245
+ recipes[i]["strength"] == chunk[0]["strength"]:
246
+ chunk.append(recipes[i])
247
+ i += 1
248
+ batches.append(chunk)
249
+
250
+ for chunk in batches:
251
+ images = render_batch(model_name, src, chunk, negative)
252
+ for r, img in zip(chunk, images):
253
+ path = os.path.join(run_dir, f"variant_{r['index']:03d}.png")
254
+ img.save(path, "PNG")
255
+ r["file"] = os.path.basename(path)
256
+ gallery.append((path, f"#{r['index']} · str {r['strength']} · {r['prompt'][:60]}"))
257
+ done += len(chunk)
258
+ progress(done / count, desc=f"{done} / {count} rendered")
259
+ yield gallery, None, f"Rendering… {done} / {count}"
260
+
261
+ manifest = os.path.join(run_dir, "manifest.csv")
262
+ with open(manifest, "w", newline="", encoding="utf-8") as f:
263
+ w = csv.DictWriter(f, fieldnames=["index", "file", "prompt", "strength", "seed"])
264
+ w.writeheader()
265
+ for r in sorted(recipes, key=lambda x: x["index"]):
266
+ w.writerow({k: r.get(k, "") for k in w.fieldnames})
267
+
268
+ bundle = os.path.join(run_dir, f"variants_{seed}.zip")
269
+ with zipfile.ZipFile(bundle, "w", zipfile.ZIP_DEFLATED) as z:
270
+ for r in recipes:
271
+ z.write(os.path.join(run_dir, r["file"]), r["file"])
272
+ z.write(manifest, "manifest.csv")
273
+
274
+ gpu_s = count * cfg["sec_per_image"]
275
+ yield (gallery, bundle,
276
+ f"**{count} variants** · base seed `{seed}` · "
277
+ f"~{gpu_s:.0f} s GPU used (~{1500 / max(gpu_s, 1):.0f} runs/day on Pro quota)")
278
+
279
+
280
+ def estimate(model_name, count):
281
+ s = MODELS[model_name]["sec_per_image"] * int(count)
282
+ return (f"≈ {s:.0f} s GPU · {s / 1500 * 100:.0f} % of a Pro day "
283
+ f"· ≈ {1500 // max(s, 1):.0f} runs before the quota resets")
284
+
285
+
286
+ # --------------------------------------------------------------------------
287
+ # Interface
288
+ # --------------------------------------------------------------------------
289
+ CSS = """
290
+ .mi-note { font-size: 0.86rem; opacity: 0.75; line-height: 1.5; }
291
+ footer { display: none !important; }
292
+ """
293
+ _MAJOR = int(gr.__version__.split(".")[0])
294
+ _STYLE = {"theme": gr.themes.Soft(), "css": CSS}
295
+ _BLOCKS_KW = {} if _MAJOR >= 6 else _STYLE
296
+ _LAUNCH_KW = _STYLE if _MAJOR >= 6 else {}
297
+
298
+ with gr.Blocks(title="Mass Iteration Studio", **_BLOCKS_KW) as demo:
299
+ gr.Markdown(
300
+ "## Mass Iteration Studio\n"
301
+ "One image in, as many PNG variants out as you ask for. "
302
+ "Colors, style and form are sampled per variant from editable pools."
303
+ )
304
+
305
+ with gr.Row():
306
+ with gr.Column(scale=4):
307
+ image = gr.Image(label="Source image", type="pil", height=260,
308
+ sources=["upload", "clipboard"])
309
+ model_name = gr.Dropdown(list(MODELS), value=DEFAULT_MODEL, label="Model")
310
+ count = gr.Slider(4, 200, 20, step=2, label="Number of variants")
311
+ budget = gr.Markdown(estimate(DEFAULT_MODEL, 20), elem_classes="mi-note")
312
+ run = gr.Button("Generate variants", variant="primary")
313
+
314
+ with gr.Accordion("How far it may drift", open=True):
315
+ smin = gr.Slider(0.15, 0.95, 0.35, step=0.05,
316
+ label="Minimum reinvention")
317
+ smax = gr.Slider(0.15, 0.95, 0.70, step=0.05,
318
+ label="Maximum reinvention")
319
+ gr.Markdown(
320
+ "0.2 recolors and restyles, 0.5 reshapes, 0.8 keeps only "
321
+ "the composition. Each variant draws a value in between.",
322
+ elem_classes="mi-note",
323
+ )
324
+
325
+ with gr.Accordion("Variation pools — one option per line", open=False):
326
+ base_prompt = gr.Textbox(
327
+ label="Constant part of the prompt",
328
+ placeholder="e.g. a stylized owl mascot, centered",
329
+ lines=2,
330
+ )
331
+ colors_raw = gr.Textbox(POOL_COLOR, label="Color", lines=6)
332
+ styles_raw = gr.Textbox(POOL_STYLE, label="Style", lines=6)
333
+ shapes_raw = gr.Textbox(POOL_SHAPE, label="Form", lines=5)
334
+ strat = gr.Radio(
335
+ ["Random mix", "Grid sweep (every combination in order)"],
336
+ value="Random mix", label="Sampling",
337
+ )
338
+
339
+ with gr.Accordion("Advanced", open=False):
340
+ seed = gr.Number(1234, label="Base seed", precision=0)
341
+ randomize = gr.Checkbox(True, label="New random seed each run")
342
+ batch_size = gr.Slider(1, 8, 4, step=1, label="Batch size per GPU call")
343
+ negative = gr.Textbox(NEGATIVE, label="Negative prompt", lines=2)
344
+
345
+ with gr.Column(scale=6):
346
+ gallery = gr.Gallery(label="Variants", columns=4, height=620,
347
+ object_fit="contain", preview=True)
348
+ status = gr.Markdown("")
349
+ bundle = gr.File(label="Download all as ZIP (+ manifest.csv)", height=90)
350
+
351
+ for c in (model_name, count):
352
+ c.change(estimate, [model_name, count], budget)
353
+
354
+ run.click(
355
+ generate,
356
+ [image, model_name, count, base_prompt, colors_raw, styles_raw, shapes_raw,
357
+ strat, smin, smax, seed, randomize, batch_size, negative],
358
+ [gallery, bundle, status],
359
+ )
360
+
361
+ if __name__ == "__main__":
362
+ demo.queue(max_size=12).launch(
363
+ server_name="0.0.0.0",
364
+ server_port=int(os.environ.get("PORT", 7860)),
365
+ ssr_mode=False,
366
+ **_LAUNCH_KW,
367
+ )
requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ # gradio comes from the Space itself (sdk_version in README) - do not pin here
2
+ spaces
3
+ torch
4
+ diffusers>=0.31.0
5
+ transformers
6
+ accelerate
7
+ peft
8
+ safetensors
9
+ pillow