Dekonstruktio commited on
Commit
d7e69be
·
verified ·
1 Parent(s): aa448e2

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +449 -114
app.py CHANGED
@@ -11,13 +11,18 @@ from PIL import Image
11
 
12
  from diffusers import QwenImage21Pipeline
13
 
 
14
  MODEL_ID = os.environ.get("MODEL_ID", "Qwen/Qwen-Image-2.1")
15
  MAX_SEED = 2**31 - 1
16
  MAX_REFERENCE_IMAGES = 10
 
 
 
 
 
 
17
 
18
- # Aspect presets. The 2K sizes are the ones published on the Qwen/Qwen-Image-2.1
19
- # model card; the 1K row is the same aspect at a quarter of the pixels, which is
20
- # what keeps a ZeroGPU request inside a sane duration.
21
  SIZE_PRESETS = {
22
  "1:1 — 1024x1024": (1024, 1024),
23
  "16:9 — 1376x768": (1376, 768),
@@ -36,16 +41,28 @@ SIZE_PRESETS = {
36
  }
37
  DEFAULT_PRESET = "1:1 — 2048x2048 (2K)"
38
 
 
 
 
 
 
 
 
 
 
39
  # The model card's recommended wrapper for native transparent output.
40
  RGBA_PREFIX = "This is an RGBA image with transparency. "
41
  RGBA_SUFFIX = " The image has alpha channel and the background is transparent."
42
 
43
- pipe = QwenImage21Pipeline.from_pretrained(MODEL_ID, torch_dtype=torch.bfloat16).to("cuda")
44
 
45
- # The decoder upcasts its resample layers to fp32, so a full-resolution 2K decode
46
- # allocates enough to take the ZeroGPU worker down. Tiling bounds that to one tile
47
- # regardless of output size; 1024 is large enough that 1K outputs still decode in a
48
- # single pass and never pay for blending.
 
 
 
 
49
  pipe.vae.enable_tiling(
50
  tile_sample_min_height=1024,
51
  tile_sample_min_width=1024,
@@ -61,67 +78,182 @@ def _resolve_seed(seed: int, randomize: bool) -> int:
61
 
62
 
63
  def _save_png(image: Image.Image) -> str:
64
- # Named temp file, never a fixed path: handlers run concurrently.
65
  fd, path = tempfile.mkstemp(suffix=".png")
66
  os.close(fd)
67
  image.save(path, format="PNG")
68
  return path
69
 
70
 
71
- # ZeroGPU scales the requested duration by ~1.5x before checking it against the
72
- # visitor's tier cap, so anything above ~195s here is rejected outright on a free
73
- # account. Keep the estimate tight: a large request also loses queue priority and
74
- # trips the quota check for visitors with little time left.
75
- MAX_DURATION = 195
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
 
77
- # Latent tokens the transformer attends over, after the pipeline's 16x spatial
78
- # compression. Condition images are resized to the output resolution, so each one
79
- # adds about as many tokens as the output itself.
80
  def _tokens(width: int, height: int) -> int:
 
81
  return (width // 16) * (height // 16)
82
 
83
 
84
- def _raw_budget(width: int, height: int, steps: int, references: int = 0) -> int:
85
- """Seconds one request needs on ZeroGPU, before clamping.
 
 
 
 
 
86
 
87
- Only the output tokens are queries; reference tokens join as keys and values
88
- and are KV-cached after the first step, so attention costs O x (O + R) rather
89
- than (O + R) squared. That asymmetry is why ten reference images are far
90
- cheaper than a 2K output. Constants fitted on timings measured here:
91
- 1024x1024/30 steps -> 17s, 1376x768/30 -> 19s, one reference/24 -> 19s,
92
- 2048x2048/30 -> ~95s.
93
  """
94
  out_tokens = _tokens(width, height)
95
  ref_tokens = out_tokens * references
96
- per_step = 9.11e-5 * out_tokens + 5.78e-9 * out_tokens * (out_tokens + ref_tokens)
 
 
 
 
97
  seconds = 12 + 4 * references + steps * per_step
 
98
  return int(max(40, seconds * 1.3))
99
 
100
 
101
- def _budget(width: int, height: int, steps: int, references: int = 0) -> int:
102
- return min(MAX_DURATION, _raw_budget(width, height, steps, references))
 
 
 
 
 
 
 
 
 
 
103
 
 
 
 
 
 
 
 
 
104
 
105
- def _require_budget(width: int, height: int, steps: int, references: int = 0) -> None:
106
- """Refuse a request that cannot finish inside the reservation we are allowed."""
107
- if _raw_budget(width, height, steps, references) > MAX_DURATION:
108
  raise gr.Error(
109
- "This request needs more GPU time than a single ZeroGPU reservation allows. "
110
- "Use fewer steps, fewer reference images, or a smaller size."
111
  )
112
 
113
 
114
- # Gradio hands these the same arguments as the handler, and gr.Examples supplies
115
- # only the columns it lists — so every parameter needs a default, and the extras
116
- # Gradio appends positionally (progress) are swallowed.
117
- def _t2i_duration(prompt=None, transparent=False, size_preset=DEFAULT_PRESET, steps=30, *args, **kwargs):
118
- width, height = SIZE_PRESETS.get(size_preset, SIZE_PRESETS[DEFAULT_PRESET])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
  return _budget(width, height, int(steps or 30))
120
 
121
 
122
- def _edit_duration(instruction=None, reference_images=None, steps=30, *args, **kwargs):
123
- count = len(reference_images or [])
124
- return _budget(1024, 1024, int(steps or 30), references=count)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
 
126
 
127
  @spaces.GPU(duration=_t2i_duration)
@@ -136,21 +268,7 @@ def generate(
136
  randomize_seed: bool = True,
137
  progress=gr.Progress(track_tqdm=True),
138
  ):
139
- """Generate an image from a text prompt with Qwen-Image-2.1.
140
-
141
- Args:
142
- prompt: What to draw. English and Chinese both work; the model is strong at rendering text inside the image.
143
- transparent: Ask for a native RGBA result with a transparent background, using the prompt format the model was trained on.
144
- size_preset: Output aspect ratio and resolution. The "(2K)" rows are the sizes published on the model card and are noticeably slower.
145
- steps: Number of denoising steps. 30 is a good default; 40 is the model card's setting.
146
- true_cfg_scale: Classifier-free guidance. Qwen-Image-2.1 is meant to be sampled at 1.0 (no guidance); raise it only to make the negative prompt take effect.
147
- negative_prompt: What to avoid. Ignored unless true_cfg_scale is above 1.0.
148
- seed: Seed for reproducible results, used when randomize_seed is off.
149
- randomize_seed: Draw a fresh random seed for this run.
150
-
151
- Returns:
152
- The generated image, a PNG file of it (alpha preserved), the seed that was used, and how long generation took.
153
- """
154
  if not prompt or not prompt.strip():
155
  raise gr.Error("Enter a prompt first.")
156
 
@@ -158,7 +276,11 @@ def generate(
158
  if transparent:
159
  text = RGBA_PREFIX + text + RGBA_SUFFIX
160
 
161
- width, height = SIZE_PRESETS.get(size_preset, SIZE_PRESETS[DEFAULT_PRESET])
 
 
 
 
162
  _require_budget(width, height, int(steps))
163
  used_seed = _resolve_seed(seed, randomize_seed)
164
 
@@ -170,23 +292,36 @@ def generate(
170
  true_cfg_scale=float(true_cfg_scale),
171
  generator=torch.Generator("cuda").manual_seed(used_seed),
172
  )
173
- if float(true_cfg_scale) > 1.0 and negative_prompt and negative_prompt.strip():
 
 
 
 
 
174
  kwargs["negative_prompt"] = negative_prompt.strip()
175
 
176
  started = time.perf_counter()
177
  image = pipe(**kwargs).images[0]
178
  elapsed = time.perf_counter() - started
179
 
 
 
180
  if not transparent and image.mode == "RGBA":
181
  image = image.convert("RGB")
182
 
183
- return image, _save_png(image), used_seed, f"{elapsed:.1f}s on {width}x{height}, {int(steps)} steps"
 
 
 
 
 
184
 
185
 
186
  @spaces.GPU(duration=_edit_duration)
187
  def edit(
188
  instruction: str,
189
  reference_images: list,
 
190
  steps: int = 30,
191
  true_cfg_scale: float = 1.0,
192
  negative_prompt: str = "",
@@ -194,55 +329,74 @@ def edit(
194
  randomize_seed: bool = True,
195
  progress=gr.Progress(track_tqdm=True),
196
  ):
197
- """Edit or recombine up to 10 reference images with a natural-language instruction.
198
-
199
- Args:
200
- instruction: What to change, e.g. "change the background to a sunset beach" or "put the person from image 1 in the room from image 2".
201
- reference_images: One to ten condition images. The model reads them as vision context, so identity and layout carry across.
202
- steps: Number of denoising steps. 30 is a good default; 40 is the model card's setting.
203
- true_cfg_scale: Classifier-free guidance. Leave at 1.0 unless you need the negative prompt to apply.
204
- negative_prompt: What to avoid. Ignored unless true_cfg_scale is above 1.0.
205
- seed: Seed for reproducible results, used when randomize_seed is off.
206
- randomize_seed: Draw a fresh random seed for this run.
207
-
208
- Returns:
209
- The edited image, a PNG file of it (alpha preserved), the seed that was used, and how long the edit took.
210
- """
211
  if not instruction or not instruction.strip():
212
  raise gr.Error("Describe the edit you want.")
 
213
  if not reference_images:
214
  raise gr.Error("Add at least one reference image.")
 
215
  if len(reference_images) > MAX_REFERENCE_IMAGES:
216
  raise gr.Error(
217
  f"Up to {MAX_REFERENCE_IMAGES} reference images are supported."
218
  )
219
 
220
  images = []
 
221
  for item in reference_images:
222
- path = item[0] if isinstance(item, (tuple, list)) else item
223
- images.append(Image.open(path))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
224
 
225
- _require_budget(1024, 1024, int(steps), references=len(images))
226
  used_seed = _resolve_seed(seed, randomize_seed)
 
227
  kwargs = dict(
228
  prompt=instruction.strip(),
229
  image=images,
 
 
230
  num_inference_steps=int(steps),
231
  true_cfg_scale=float(true_cfg_scale),
232
  generator=torch.Generator("cuda").manual_seed(used_seed),
233
  )
234
- if float(true_cfg_scale) > 1.0 and negative_prompt and negative_prompt.strip():
 
 
 
 
 
235
  kwargs["negative_prompt"] = negative_prompt.strip()
236
 
237
  started = time.perf_counter()
238
  image = pipe(**kwargs).images[0]
239
  elapsed = time.perf_counter() - started
240
 
241
- return image, _save_png(image), used_seed, f"{elapsed:.1f}s from {len(images)} reference image(s), {int(steps)} steps"
 
 
 
 
 
 
 
242
 
243
 
244
  INTRO = """ """
245
 
 
246
  with gr.Blocks(title=" ") as demo:
247
  gr.Markdown(INTRO)
248
 
@@ -254,44 +408,126 @@ with gr.Blocks(title=" ") as demo:
254
  label=" ",
255
  lines=10,
256
  )
 
257
  t2i_transparent = gr.Checkbox(
258
  label=" ",
259
  value=False,
260
  )
 
261
  t2i_size = gr.Dropdown(
262
  label=" ",
263
  choices=list(SIZE_PRESETS),
264
  value=DEFAULT_PRESET,
265
  )
266
- t2i_run = gr.Button(" ", variant="primary")
 
 
 
 
267
 
268
  with gr.Accordion(" ", open=False):
269
- t2i_steps = gr.Slider(10, 50, value=25, step=1, label=" ")
 
 
 
 
 
 
 
270
  t2i_cfg = gr.Slider(
271
- 1.0, 10.0, value=1.1, step=0.1,
 
 
 
 
 
 
 
272
  label=" ",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
273
  )
274
- t2i_negative = gr.Textbox(label=" ", lines=2)
275
- t2i_seed = gr.Slider(0, MAX_SEED, value=0, step=1, label=" ")
276
- t2i_random = gr.Checkbox(label=" ", value=True)
277
 
278
  with gr.Column(scale=1):
279
- t2i_image = gr.Image(label=" ", type="pil", format="png", height=375)
280
- t2i_file = gr.File(label=" ", visible=False)
 
 
 
 
 
 
 
 
 
 
281
  with gr.Row():
282
- t2i_used_seed = gr.Number(label=" ", interactive=False, visible=False)
283
- t2i_timing = gr.Textbox(label=" ", interactive=False, visible=False)
 
 
 
 
 
 
 
 
 
284
 
285
  gr.Examples(
286
  examples=[
287
- ['A neon shop sign that reads "QWEN IMAGE 2.1", rainy night, reflections on wet pavement', False, "16:9 — 1376x768"],
288
- ["A capybara wearing a wizard hat, reading a book by candlelight, oil painting", False, "1:1 — 1024x1024"],
289
- ["A cute cartoon dragon sticker", True, "1:1 — 1024x1024"],
290
- ["A glossy 3D icon of a paper plane, soft studio lighting", True, "1:1 — 1024x1024"],
291
- ["A handwritten chalkboard menu in a Parisian cafe, legible French text, warm morning light", False, "3:4 — 896x1200"],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
292
  ],
293
- inputs=[t2i_prompt, t2i_transparent, t2i_size],
294
- outputs=[t2i_image, t2i_file, t2i_used_seed, t2i_timing],
295
  fn=generate,
296
  cache_examples=True,
297
  cache_mode="lazy",
@@ -305,6 +541,7 @@ with gr.Blocks(title=" ") as demo:
305
  label=" ",
306
  lines=6,
307
  )
 
308
  edit_refs = gr.Gallery(
309
  label=" ",
310
  type="filepath",
@@ -312,47 +549,145 @@ with gr.Blocks(title=" ") as demo:
312
  height=344,
313
  interactive=True,
314
  )
315
- edit_run = gr.Button(" ", variant="primary")
 
 
 
 
 
 
 
 
 
 
316
 
317
  with gr.Accordion(" ", open=False):
318
- edit_steps = gr.Slider(10, 50, value=25, step=1, label=" ")
 
 
 
 
 
 
 
319
  edit_cfg = gr.Slider(
320
- 1.0, 10.0, value=1.1, step=0.1,
 
 
 
321
  label=" ",
322
  )
323
- edit_negative = gr.Textbox(label=" ", lines=2)
324
- edit_seed = gr.Slider(0, MAX_SEED, value=0, step=1, label=" ")
325
- edit_random = gr.Checkbox(label=" ", value=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
326
 
327
  with gr.Column(scale=1):
328
- edit_image = gr.Image(label=" ", type="pil", format="png", height=344)
329
- edit_file = gr.File(label=" ", visible=False)
 
 
 
 
 
 
 
 
 
 
330
  with gr.Row():
331
- edit_used_seed = gr.Number(label=" ", interactive=False, visible=False)
332
- edit_timing = gr.Textbox(label=" ", interactive=False, visible=False)
 
 
 
333
 
334
- gr.Markdown(
335
- " "
336
- )
 
 
 
 
337
 
338
  t2i_run.click(
339
  fn=generate,
340
- inputs=[t2i_prompt, t2i_transparent, t2i_size, t2i_steps, t2i_cfg, t2i_negative, t2i_seed, t2i_random],
341
- outputs=[t2i_image, t2i_file, t2i_used_seed, t2i_timing],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
342
  api_name="generate",
343
  )
 
344
  t2i_prompt.submit(
345
  fn=generate,
346
- inputs=[t2i_prompt, t2i_transparent, t2i_size, t2i_steps, t2i_cfg, t2i_negative, t2i_seed, t2i_random],
347
- outputs=[t2i_image, t2i_file, t2i_used_seed, t2i_timing],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
348
  api_name=False,
349
  )
 
350
  edit_run.click(
351
  fn=edit,
352
- inputs=[edit_instruction, edit_refs, edit_steps, edit_cfg, edit_negative, edit_seed, edit_random],
353
- outputs=[edit_image, edit_file, edit_used_seed, edit_timing],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
354
  api_name="edit",
355
  )
356
 
 
357
  if __name__ == "__main__":
358
  demo.launch(mcp_server=False)
 
 
11
 
12
  from diffusers import QwenImage21Pipeline
13
 
14
+
15
  MODEL_ID = os.environ.get("MODEL_ID", "Qwen/Qwen-Image-2.1")
16
  MAX_SEED = 2**31 - 1
17
  MAX_REFERENCE_IMAGES = 10
18
+ MAX_DURATION = 195
19
+
20
+ # Qwen Image's latent-space geometry uses 16x spatial compression.
21
+ SIZE_MULTIPLE = 16
22
+ MAX_EDIT_LONG_SIDE = 2048
23
+
24
 
25
+ # Text-to-image output presets.
 
 
26
  SIZE_PRESETS = {
27
  "1:1 — 1024x1024": (1024, 1024),
28
  "16:9 — 1376x768": (1376, 768),
 
41
  }
42
  DEFAULT_PRESET = "1:1 — 2048x2048 (2K)"
43
 
44
+
45
+ # Image-to-image output settings.
46
+ EDIT_SIZE_PRESETS = {
47
+ "Square — 2048x2048": "square_2k",
48
+ "Preserve first image aspect — long side 2048": "long_side_2k",
49
+ }
50
+ DEFAULT_EDIT_SIZE = "Preserve first image aspect — long side 2048"
51
+
52
+
53
  # The model card's recommended wrapper for native transparent output.
54
  RGBA_PREFIX = "This is an RGBA image with transparency. "
55
  RGBA_SUFFIX = " The image has alpha channel and the background is transparent."
56
 
 
57
 
58
+ pipe = QwenImage21Pipeline.from_pretrained(
59
+ MODEL_ID,
60
+ torch_dtype=torch.bfloat16,
61
+ ).to("cuda")
62
+
63
+
64
+ # Avoid a large full-frame VAE decode. At 1024px, ordinary 1K images still decode
65
+ # in one tile, while larger outputs use tiled decoding and require less peak VRAM.
66
  pipe.vae.enable_tiling(
67
  tile_sample_min_height=1024,
68
  tile_sample_min_width=1024,
 
78
 
79
 
80
  def _save_png(image: Image.Image) -> str:
81
+ """Save an image as a unique PNG file and return its path."""
82
  fd, path = tempfile.mkstemp(suffix=".png")
83
  os.close(fd)
84
  image.save(path, format="PNG")
85
  return path
86
 
87
 
88
+ def _round_to_multiple(value: float, multiple: int = SIZE_MULTIPLE) -> int:
89
+ """Round a dimension to the nearest valid Qwen latent-grid multiple."""
90
+ return max(multiple, int(round(value / multiple)) * multiple)
91
+
92
+
93
+ def _edit_output_size(
94
+ first_image: Image.Image,
95
+ edit_size: str = DEFAULT_EDIT_SIZE,
96
+ ) -> tuple[int, int]:
97
+ """Determine the target canvas for an image-edit request.
98
+
99
+ Square mode always produces 2048x2048.
100
+
101
+ Long-side mode preserves the first reference image's aspect ratio, scales its
102
+ longest side to 2048 px, and rounds both dimensions to multiples of 16.
103
+ """
104
+ mode = EDIT_SIZE_PRESETS.get(
105
+ edit_size,
106
+ EDIT_SIZE_PRESETS[DEFAULT_EDIT_SIZE],
107
+ )
108
+
109
+ if mode == "square_2k":
110
+ return 2048, 2048
111
+
112
+ source_width, source_height = first_image.size
113
+ if source_width <= 0 or source_height <= 0:
114
+ raise gr.Error("The first reference image has invalid dimensions.")
115
+
116
+ scale = MAX_EDIT_LONG_SIDE / max(source_width, source_height)
117
+
118
+ width = _round_to_multiple(source_width * scale)
119
+ height = _round_to_multiple(source_height * scale)
120
+
121
+ # Rounding upward can make an edge 16 pixels too large. Scale down once more
122
+ # so that "long side 2048" always remains a real upper bound.
123
+ if max(width, height) > MAX_EDIT_LONG_SIDE:
124
+ second_scale = MAX_EDIT_LONG_SIDE / max(width, height)
125
+ width = _round_to_multiple(width * second_scale)
126
+ height = _round_to_multiple(height * second_scale)
127
+
128
+ # This is a final guard against edge cases from rounding.
129
+ width = min(width, MAX_EDIT_LONG_SIDE)
130
+ height = min(height, MAX_EDIT_LONG_SIDE)
131
+
132
+ return width, height
133
+
134
 
 
 
 
135
  def _tokens(width: int, height: int) -> int:
136
+ """Number of spatial latent tokens after 16x compression."""
137
  return (width // 16) * (height // 16)
138
 
139
 
140
+ def _raw_budget(
141
+ width: int,
142
+ height: int,
143
+ steps: int,
144
+ references: int = 0,
145
+ ) -> int:
146
+ """Estimate the un-clamped ZeroGPU duration requirement.
147
 
148
+ The output latents form attention queries. Reference-image latents are
149
+ key/value context and are cached after the first step. This makes the
150
+ approximate attention cost O x (O + R), not (O + R)^2.
 
 
 
151
  """
152
  out_tokens = _tokens(width, height)
153
  ref_tokens = out_tokens * references
154
+
155
+ per_step = (
156
+ 9.11e-5 * out_tokens
157
+ + 5.78e-9 * out_tokens * (out_tokens + ref_tokens)
158
+ )
159
  seconds = 12 + 4 * references + steps * per_step
160
+
161
  return int(max(40, seconds * 1.3))
162
 
163
 
164
+ def _budget(
165
+ width: int,
166
+ height: int,
167
+ steps: int,
168
+ references: int = 0,
169
+ ) -> int:
170
+ """Return a reservation duration no larger than ZeroGPU permits."""
171
+ return min(
172
+ MAX_DURATION,
173
+ _raw_budget(width, height, steps, references),
174
+ )
175
+
176
 
177
+ def _require_budget(
178
+ width: int,
179
+ height: int,
180
+ steps: int,
181
+ references: int = 0,
182
+ ) -> None:
183
+ """Reject work that cannot fit inside a single allowed ZeroGPU reservation."""
184
+ required = _raw_budget(width, height, steps, references)
185
 
186
+ if required > MAX_DURATION:
 
 
187
  raise gr.Error(
188
+ "This request needs more GPU time than a single ZeroGPU reservation "
189
+ "allows. Use fewer steps, fewer reference images, or a smaller size."
190
  )
191
 
192
 
193
+ def _gallery_item_path(item) -> str:
194
+ """Extract a filepath from Gradio Gallery's supported return formats."""
195
+ if isinstance(item, (tuple, list)):
196
+ return item[0]
197
+ return item
198
+
199
+
200
+ def _t2i_duration(
201
+ prompt=None,
202
+ transparent=False,
203
+ size_preset=DEFAULT_PRESET,
204
+ steps=30,
205
+ *args,
206
+ **kwargs,
207
+ ):
208
+ width, height = SIZE_PRESETS.get(
209
+ size_preset,
210
+ SIZE_PRESETS[DEFAULT_PRESET],
211
+ )
212
  return _budget(width, height, int(steps or 30))
213
 
214
 
215
+ def _edit_duration(
216
+ instruction=None,
217
+ reference_images=None,
218
+ edit_size=DEFAULT_EDIT_SIZE,
219
+ steps=30,
220
+ *args,
221
+ **kwargs,
222
+ ):
223
+ """Estimate image-edit duration from the actual selected output geometry."""
224
+ references = reference_images or []
225
+ count = len(references)
226
+
227
+ # Before an image is uploaded, reserve enough for the selected 2K operation.
228
+ if not references:
229
+ return _budget(
230
+ 2048,
231
+ 2048,
232
+ int(steps or 30),
233
+ references=0,
234
+ )
235
+
236
+ try:
237
+ first_path = _gallery_item_path(references[0])
238
+
239
+ with Image.open(first_path) as first_image:
240
+ width, height = _edit_output_size(first_image, edit_size)
241
+
242
+ return _budget(
243
+ width,
244
+ height,
245
+ int(steps or 30),
246
+ references=count,
247
+ )
248
+ except Exception:
249
+ # The browser may be mid-upload when the duration callback is evaluated.
250
+ # Use a safe upper-bound fallback in that situation.
251
+ return _budget(
252
+ 2048,
253
+ 2048,
254
+ int(steps or 30),
255
+ references=count,
256
+ )
257
 
258
 
259
  @spaces.GPU(duration=_t2i_duration)
 
268
  randomize_seed: bool = True,
269
  progress=gr.Progress(track_tqdm=True),
270
  ):
271
+ """Generate an image from a text prompt."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
272
  if not prompt or not prompt.strip():
273
  raise gr.Error("Enter a prompt first.")
274
 
 
276
  if transparent:
277
  text = RGBA_PREFIX + text + RGBA_SUFFIX
278
 
279
+ width, height = SIZE_PRESETS.get(
280
+ size_preset,
281
+ SIZE_PRESETS[DEFAULT_PRESET],
282
+ )
283
+
284
  _require_budget(width, height, int(steps))
285
  used_seed = _resolve_seed(seed, randomize_seed)
286
 
 
292
  true_cfg_scale=float(true_cfg_scale),
293
  generator=torch.Generator("cuda").manual_seed(used_seed),
294
  )
295
+
296
+ if (
297
+ float(true_cfg_scale) > 1.0
298
+ and negative_prompt
299
+ and negative_prompt.strip()
300
+ ):
301
  kwargs["negative_prompt"] = negative_prompt.strip()
302
 
303
  started = time.perf_counter()
304
  image = pipe(**kwargs).images[0]
305
  elapsed = time.perf_counter() - started
306
 
307
+ # An opaque text-to-image result is exported as RGB PNG. Native transparent
308
+ # results retain their RGBA alpha channel.
309
  if not transparent and image.mode == "RGBA":
310
  image = image.convert("RGB")
311
 
312
+ return (
313
+ image,
314
+ _save_png(image),
315
+ used_seed,
316
+ f"{elapsed:.1f}s on {width}x{height}, {int(steps)} steps",
317
+ )
318
 
319
 
320
  @spaces.GPU(duration=_edit_duration)
321
  def edit(
322
  instruction: str,
323
  reference_images: list,
324
+ edit_size: str = DEFAULT_EDIT_SIZE,
325
  steps: int = 30,
326
  true_cfg_scale: float = 1.0,
327
  negative_prompt: str = "",
 
329
  randomize_seed: bool = True,
330
  progress=gr.Progress(track_tqdm=True),
331
  ):
332
+ """Edit or recombine up to ten images using a natural-language instruction."""
 
 
 
 
 
 
 
 
 
 
 
 
 
333
  if not instruction or not instruction.strip():
334
  raise gr.Error("Describe the edit you want.")
335
+
336
  if not reference_images:
337
  raise gr.Error("Add at least one reference image.")
338
+
339
  if len(reference_images) > MAX_REFERENCE_IMAGES:
340
  raise gr.Error(
341
  f"Up to {MAX_REFERENCE_IMAGES} reference images are supported."
342
  )
343
 
344
  images = []
345
+
346
  for item in reference_images:
347
+ path = _gallery_item_path(item)
348
+
349
+ # Copy while the input file is open. This closes file handles before GPU
350
+ # inference and avoids lazy reads from temporary Gradio upload paths.
351
+ with Image.open(path) as source:
352
+ images.append(source.copy())
353
+
354
+ # The first reference defines the aspect ratio for long-side mode.
355
+ width, height = _edit_output_size(images[0], edit_size)
356
+
357
+ _require_budget(
358
+ width,
359
+ height,
360
+ int(steps),
361
+ references=len(images),
362
+ )
363
 
 
364
  used_seed = _resolve_seed(seed, randomize_seed)
365
+
366
  kwargs = dict(
367
  prompt=instruction.strip(),
368
  image=images,
369
+ width=width,
370
+ height=height,
371
  num_inference_steps=int(steps),
372
  true_cfg_scale=float(true_cfg_scale),
373
  generator=torch.Generator("cuda").manual_seed(used_seed),
374
  )
375
+
376
+ if (
377
+ float(true_cfg_scale) > 1.0
378
+ and negative_prompt
379
+ and negative_prompt.strip()
380
+ ):
381
  kwargs["negative_prompt"] = negative_prompt.strip()
382
 
383
  started = time.perf_counter()
384
  image = pipe(**kwargs).images[0]
385
  elapsed = time.perf_counter() - started
386
 
387
+ # _save_png preserves RGBA alpha when the pipeline supplies it.
388
+ return (
389
+ image,
390
+ _save_png(image),
391
+ used_seed,
392
+ f"{elapsed:.1f}s from {len(images)} reference image(s), "
393
+ f"{width}x{height}, {int(steps)} steps",
394
+ )
395
 
396
 
397
  INTRO = """ """
398
 
399
+
400
  with gr.Blocks(title=" ") as demo:
401
  gr.Markdown(INTRO)
402
 
 
408
  label=" ",
409
  lines=10,
410
  )
411
+
412
  t2i_transparent = gr.Checkbox(
413
  label=" ",
414
  value=False,
415
  )
416
+
417
  t2i_size = gr.Dropdown(
418
  label=" ",
419
  choices=list(SIZE_PRESETS),
420
  value=DEFAULT_PRESET,
421
  )
422
+
423
+ t2i_run = gr.Button(
424
+ " ",
425
+ variant="primary",
426
+ )
427
 
428
  with gr.Accordion(" ", open=False):
429
+ t2i_steps = gr.Slider(
430
+ 10,
431
+ 50,
432
+ value=25,
433
+ step=1,
434
+ label=" ",
435
+ )
436
+
437
  t2i_cfg = gr.Slider(
438
+ 1.0,
439
+ 10.0,
440
+ value=1.1,
441
+ step=0.1,
442
+ label=" ",
443
+ )
444
+
445
+ t2i_negative = gr.Textbox(
446
  label=" ",
447
+ lines=2,
448
+ )
449
+
450
+ t2i_seed = gr.Slider(
451
+ 0,
452
+ MAX_SEED,
453
+ value=0,
454
+ step=1,
455
+ label=" ",
456
+ )
457
+
458
+ t2i_random = gr.Checkbox(
459
+ label=" ",
460
+ value=True,
461
  )
 
 
 
462
 
463
  with gr.Column(scale=1):
464
+ t2i_image = gr.Image(
465
+ label=" ",
466
+ type="pil",
467
+ format="png",
468
+ height=375,
469
+ )
470
+
471
+ t2i_file = gr.File(
472
+ label=" ",
473
+ visible=False,
474
+ )
475
+
476
  with gr.Row():
477
+ t2i_used_seed = gr.Number(
478
+ label=" ",
479
+ interactive=False,
480
+ visible=False,
481
+ )
482
+
483
+ t2i_timing = gr.Textbox(
484
+ label=" ",
485
+ interactive=False,
486
+ visible=False,
487
+ )
488
 
489
  gr.Examples(
490
  examples=[
491
+ [
492
+ 'A neon shop sign that reads "QWEN IMAGE 2.1", '
493
+ "rainy night, reflections on wet pavement",
494
+ False,
495
+ "16:9 — 1376x768",
496
+ ],
497
+ [
498
+ "A capybara wearing a wizard hat, reading a book by "
499
+ "candlelight, oil painting",
500
+ False,
501
+ "1:1 — 1024x1024",
502
+ ],
503
+ [
504
+ "A cute cartoon dragon sticker",
505
+ True,
506
+ "1:1 — 1024x1024",
507
+ ],
508
+ [
509
+ "A glossy 3D icon of a paper plane, soft studio lighting",
510
+ True,
511
+ "1:1 — 1024x1024",
512
+ ],
513
+ [
514
+ "A handwritten chalkboard menu in a Parisian cafe, "
515
+ "legible French text, warm morning light",
516
+ False,
517
+ "3:4 — 896x1200",
518
+ ],
519
+ ],
520
+ inputs=[
521
+ t2i_prompt,
522
+ t2i_transparent,
523
+ t2i_size,
524
+ ],
525
+ outputs=[
526
+ t2i_image,
527
+ t2i_file,
528
+ t2i_used_seed,
529
+ t2i_timing,
530
  ],
 
 
531
  fn=generate,
532
  cache_examples=True,
533
  cache_mode="lazy",
 
541
  label=" ",
542
  lines=6,
543
  )
544
+
545
  edit_refs = gr.Gallery(
546
  label=" ",
547
  type="filepath",
 
549
  height=344,
550
  interactive=True,
551
  )
552
+
553
+ edit_size = gr.Radio(
554
+ label="Output size",
555
+ choices=list(EDIT_SIZE_PRESETS),
556
+ value=DEFAULT_EDIT_SIZE,
557
+ )
558
+
559
+ edit_run = gr.Button(
560
+ " ",
561
+ variant="primary",
562
+ )
563
 
564
  with gr.Accordion(" ", open=False):
565
+ edit_steps = gr.Slider(
566
+ 10,
567
+ 50,
568
+ value=25,
569
+ step=1,
570
+ label=" ",
571
+ )
572
+
573
  edit_cfg = gr.Slider(
574
+ 1.0,
575
+ 10.0,
576
+ value=1.1,
577
+ step=0.1,
578
  label=" ",
579
  )
580
+
581
+ edit_negative = gr.Textbox(
582
+ label=" ",
583
+ lines=2,
584
+ )
585
+
586
+ edit_seed = gr.Slider(
587
+ 0,
588
+ MAX_SEED,
589
+ value=0,
590
+ step=1,
591
+ label=" ",
592
+ )
593
+
594
+ edit_random = gr.Checkbox(
595
+ label=" ",
596
+ value=True,
597
+ )
598
 
599
  with gr.Column(scale=1):
600
+ edit_image = gr.Image(
601
+ label=" ",
602
+ type="pil",
603
+ format="png",
604
+ height=344,
605
+ )
606
+
607
+ edit_file = gr.File(
608
+ label=" ",
609
+ visible=False,
610
+ )
611
+
612
  with gr.Row():
613
+ edit_used_seed = gr.Number(
614
+ label=" ",
615
+ interactive=False,
616
+ visible=False,
617
+ )
618
 
619
+ edit_timing = gr.Textbox(
620
+ label=" ",
621
+ interactive=False,
622
+ visible=False,
623
+ )
624
+
625
+ gr.Markdown(" ")
626
 
627
  t2i_run.click(
628
  fn=generate,
629
+ inputs=[
630
+ t2i_prompt,
631
+ t2i_transparent,
632
+ t2i_size,
633
+ t2i_steps,
634
+ t2i_cfg,
635
+ t2i_negative,
636
+ t2i_seed,
637
+ t2i_random,
638
+ ],
639
+ outputs=[
640
+ t2i_image,
641
+ t2i_file,
642
+ t2i_used_seed,
643
+ t2i_timing,
644
+ ],
645
  api_name="generate",
646
  )
647
+
648
  t2i_prompt.submit(
649
  fn=generate,
650
+ inputs=[
651
+ t2i_prompt,
652
+ t2i_transparent,
653
+ t2i_size,
654
+ t2i_steps,
655
+ t2i_cfg,
656
+ t2i_negative,
657
+ t2i_seed,
658
+ t2i_random,
659
+ ],
660
+ outputs=[
661
+ t2i_image,
662
+ t2i_file,
663
+ t2i_used_seed,
664
+ t2i_timing,
665
+ ],
666
  api_name=False,
667
  )
668
+
669
  edit_run.click(
670
  fn=edit,
671
+ inputs=[
672
+ edit_instruction,
673
+ edit_refs,
674
+ edit_size,
675
+ edit_steps,
676
+ edit_cfg,
677
+ edit_negative,
678
+ edit_seed,
679
+ edit_random,
680
+ ],
681
+ outputs=[
682
+ edit_image,
683
+ edit_file,
684
+ edit_used_seed,
685
+ edit_timing,
686
+ ],
687
  api_name="edit",
688
  )
689
 
690
+
691
  if __name__ == "__main__":
692
  demo.launch(mcp_server=False)
693
+