Visual / app.py
Dekonstruktio's picture
Update app.py
fb12ff8 verified
Raw History Blame Contribute Delete
19.7 kB
import spaces # must precede torch / diffusers
import os
import random
import tempfile
import time
import gradio as gr
import torch
from PIL import Image
from diffusers import QwenImage21Pipeline
MODEL_ID = os.environ.get("MODEL_ID", "Qwen/Qwen-Image-2.1")
MAX_SEED = 2**31 - 1
MAX_REFERENCE_IMAGES = 10
MAX_DURATION = 195
# Qwen Image's latent-space geometry uses 16x spatial compression.
SIZE_MULTIPLE = 16
MAX_EDIT_LONG_SIDE = 2048
# Text-to-image output presets.
SIZE_PRESETS = {
"1:1 — 1024x1024": (1024, 1024),
"16:9 — 1376x768": (1376, 768),
"9:16 — 768x1376": (768, 1376),
"4:3 — 1200x896": (1200, 896),
"3:4 — 896x1200": (896, 1200),
"3:2 — 1264x848": (1264, 848),
"2:3 — 848x1264": (848, 1264),
"1:1 — 2048x2048 (2K)": (2048, 2048),
"16:9 — 2752x1536 (2K)": (2752, 1536),
"9:16 — 1536x2752 (2K)": (1536, 2752),
"4:3 — 2400x1792 (2K)": (2400, 1792),
"3:4 — 1792x2400 (2K)": (1792, 2400),
"3:2 — 2528x1696 (2K)": (2528, 1696),
"2:3 — 1696x2528 (2K)": (1696, 2528),
}
DEFAULT_PRESET = "1:1 — 2048x2048 (2K)"
# Image-to-image output settings.
EDIT_SIZE_PRESETS = {
"2048x2048": "square_2k",
"2048": "long_side_2k",
}
DEFAULT_EDIT_SIZE = "2048x2048"
# The model card's recommended wrapper for native transparent output.
RGBA_PREFIX = "This is an RGBA image with transparency. "
RGBA_SUFFIX = " The image has alpha channel and the background is transparent."
pipe = QwenImage21Pipeline.from_pretrained(
MODEL_ID,
torch_dtype=torch.bfloat16,
).to("cuda")
# Avoid a large full-frame VAE decode. At 1024px, ordinary 1K images still decode
# in one tile, while larger outputs use tiled decoding and require less peak VRAM.
pipe.vae.enable_tiling(
tile_sample_min_height=1024,
tile_sample_min_width=1024,
tile_sample_stride_height=768,
tile_sample_stride_width=768,
)
def _resolve_seed(seed: int, randomize: bool) -> int:
if randomize or seed is None or int(seed) < 0:
return random.randint(0, MAX_SEED)
return int(seed) % (MAX_SEED + 1)
def _save_png(image: Image.Image) -> str:
"""Save an image as a unique PNG file and return its path."""
fd, path = tempfile.mkstemp(suffix=".png")
os.close(fd)
image.save(path, format="PNG")
return path
def _round_to_multiple(value: float, multiple: int = SIZE_MULTIPLE) -> int:
"""Round a dimension to the nearest valid Qwen latent-grid multiple."""
return max(multiple, int(round(value / multiple)) * multiple)
def _edit_output_size(
first_image: Image.Image,
edit_size: str = DEFAULT_EDIT_SIZE,
) -> tuple[int, int]:
"""Determine the target canvas for an image-edit request.
Square mode always produces 2048x2048.
Long-side mode preserves the first reference image's aspect ratio, scales its
longest side to 2048 px, and rounds both dimensions to multiples of 16.
"""
mode = EDIT_SIZE_PRESETS.get(
edit_size,
EDIT_SIZE_PRESETS[DEFAULT_EDIT_SIZE],
)
if mode == "square_2k":
return 2048, 2048
source_width, source_height = first_image.size
if source_width <= 0 or source_height <= 0:
raise gr.Error("The first reference image has invalid dimensions.")
scale = MAX_EDIT_LONG_SIDE / max(source_width, source_height)
width = _round_to_multiple(source_width * scale)
height = _round_to_multiple(source_height * scale)
# Rounding upward can make an edge 16 pixels too large. Scale down once more
# so that "long side 2048" always remains a real upper bound.
if max(width, height) > MAX_EDIT_LONG_SIDE:
second_scale = MAX_EDIT_LONG_SIDE / max(width, height)
width = _round_to_multiple(width * second_scale)
height = _round_to_multiple(height * second_scale)
# This is a final guard against edge cases from rounding.
width = min(width, MAX_EDIT_LONG_SIDE)
height = min(height, MAX_EDIT_LONG_SIDE)
return width, height
def _tokens(width: int, height: int) -> int:
"""Number of spatial latent tokens after 16x compression."""
return (width // 16) * (height // 16)
def _raw_budget(
width: int,
height: int,
steps: int,
references: int = 0,
) -> int:
"""Estimate the un-clamped ZeroGPU duration requirement.
The output latents form attention queries. Reference-image latents are
key/value context and are cached after the first step. This makes the
approximate attention cost O x (O + R), not (O + R)^2.
"""
out_tokens = _tokens(width, height)
ref_tokens = out_tokens * references
per_step = (
9.11e-5 * out_tokens
+ 5.78e-9 * out_tokens * (out_tokens + ref_tokens)
)
seconds = 12 + 4 * references + steps * per_step
return int(max(40, seconds * 1.3))
def _budget(
width: int,
height: int,
steps: int,
references: int = 0,
) -> int:
"""Return a reservation duration no larger than ZeroGPU permits."""
return min(
MAX_DURATION,
_raw_budget(width, height, steps, references),
)
def _require_budget(
width: int,
height: int,
steps: int,
references: int = 0,
) -> None:
"""Reject work that cannot fit inside a single allowed ZeroGPU reservation."""
required = _raw_budget(width, height, steps, references)
if required > MAX_DURATION:
raise gr.Error(
"This request needs more GPU time than a single ZeroGPU reservation "
"allows. Use fewer steps, fewer reference images, or a smaller size."
)
def _gallery_item_path(item) -> str:
"""Extract a filepath from Gradio Gallery's supported return formats."""
if isinstance(item, (tuple, list)):
return item[0]
return item
def _t2i_duration(
prompt=None,
transparent=False,
size_preset=DEFAULT_PRESET,
steps=30,
*args,
**kwargs,
):
width, height = SIZE_PRESETS.get(
size_preset,
SIZE_PRESETS[DEFAULT_PRESET],
)
return _budget(width, height, int(steps or 30))
def _edit_duration(
instruction=None,
reference_images=None,
edit_size=DEFAULT_EDIT_SIZE,
steps=30,
*args,
**kwargs,
):
"""Estimate image-edit duration from the actual selected output geometry."""
references = reference_images or []
count = len(references)
# Before an image is uploaded, reserve enough for the selected 2K operation.
if not references:
return _budget(
2048,
2048,
int(steps or 30),
references=0,
)
try:
first_path = _gallery_item_path(references[0])
with Image.open(first_path) as first_image:
width, height = _edit_output_size(first_image, edit_size)
return _budget(
width,
height,
int(steps or 30),
references=count,
)
except Exception:
# The browser may be mid-upload when the duration callback is evaluated.
# Use a safe upper-bound fallback in that situation.
return _budget(
2048,
2048,
int(steps or 30),
references=count,
)
@spaces.GPU(duration=_t2i_duration)
def generate(
prompt: str,
transparent: bool = False,
size_preset: str = DEFAULT_PRESET,
steps: int = 30,
true_cfg_scale: float = 1.0,
negative_prompt: str = "",
seed: int = 0,
randomize_seed: bool = True,
progress=gr.Progress(track_tqdm=True),
):
"""Generate an image from a text prompt."""
if not prompt or not prompt.strip():
raise gr.Error("Enter a prompt first.")
text = prompt.strip()
if transparent:
text = RGBA_PREFIX + text + RGBA_SUFFIX
width, height = SIZE_PRESETS.get(
size_preset,
SIZE_PRESETS[DEFAULT_PRESET],
)
_require_budget(width, height, int(steps))
used_seed = _resolve_seed(seed, randomize_seed)
kwargs = dict(
prompt=text,
width=width,
height=height,
num_inference_steps=int(steps),
true_cfg_scale=float(true_cfg_scale),
generator=torch.Generator("cuda").manual_seed(used_seed),
)
if (
float(true_cfg_scale) > 1.0
and negative_prompt
and negative_prompt.strip()
):
kwargs["negative_prompt"] = negative_prompt.strip()
started = time.perf_counter()
image = pipe(**kwargs).images[0]
elapsed = time.perf_counter() - started
# An opaque text-to-image result is exported as RGB PNG. Native transparent
# results retain their RGBA alpha channel.
if not transparent and image.mode == "RGBA":
image = image.convert("RGB")
return (
image,
_save_png(image),
used_seed,
f"{elapsed:.1f}s on {width}x{height}, {int(steps)} steps",
)
@spaces.GPU(duration=_edit_duration)
def edit(
instruction: str,
reference_images: list,
edit_size: str = DEFAULT_EDIT_SIZE,
steps: int = 30,
true_cfg_scale: float = 1.0,
negative_prompt: str = "",
seed: int = 0,
randomize_seed: bool = True,
progress=gr.Progress(track_tqdm=True),
):
"""Edit or recombine up to ten images using a natural-language instruction."""
if not instruction or not instruction.strip():
raise gr.Error("Describe the edit you want.")
if not reference_images:
raise gr.Error("Add at least one reference image.")
if len(reference_images) > MAX_REFERENCE_IMAGES:
raise gr.Error(
f"Up to {MAX_REFERENCE_IMAGES} reference images are supported."
)
images = []
for item in reference_images:
path = _gallery_item_path(item)
# Copy while the input file is open. This closes file handles before GPU
# inference and avoids lazy reads from temporary Gradio upload paths.
with Image.open(path) as source:
images.append(source.copy())
# The first reference defines the aspect ratio for long-side mode.
width, height = _edit_output_size(images[0], edit_size)
_require_budget(
width,
height,
int(steps),
references=len(images),
)
used_seed = _resolve_seed(seed, randomize_seed)
kwargs = dict(
prompt=instruction.strip(),
image=images,
width=width,
height=height,
num_inference_steps=int(steps),
true_cfg_scale=float(true_cfg_scale),
generator=torch.Generator("cuda").manual_seed(used_seed),
)
if (
float(true_cfg_scale) > 1.0
and negative_prompt
and negative_prompt.strip()
):
kwargs["negative_prompt"] = negative_prompt.strip()
started = time.perf_counter()
image = pipe(**kwargs).images[0]
elapsed = time.perf_counter() - started
# _save_png preserves RGBA alpha when the pipeline supplies it.
return (
image,
_save_png(image),
used_seed,
f"{elapsed:.1f}s from {len(images)} reference image(s), "
f"{width}x{height}, {int(steps)} steps",
)
INTRO = """ """
with gr.Blocks(title=" ") as demo:
gr.Markdown(INTRO)
with gr.Tabs():
with gr.Tab(" "):
with gr.Row():
with gr.Column(scale=1):
t2i_prompt = gr.Textbox(
label=" ",
lines=10,
)
t2i_transparent = gr.Checkbox(
label=" ",
value=False,
)
t2i_size = gr.Dropdown(
label=" ",
choices=list(SIZE_PRESETS),
value=DEFAULT_PRESET,
)
t2i_run = gr.Button(
" ",
variant="primary",
)
with gr.Accordion(" ", open=False):
t2i_steps = gr.Slider(
10,
50,
value=25,
step=1,
label=" ",
)
t2i_cfg = gr.Slider(
1.0,
10.0,
value=1.1,
step=0.1,
label=" ",
)
t2i_negative = gr.Textbox(
label=" ",
lines=2,
)
t2i_seed = gr.Slider(
0,
MAX_SEED,
value=0,
step=1,
label=" ",
)
t2i_random = gr.Checkbox(
label=" ",
value=True,
)
with gr.Column(scale=1):
t2i_image = gr.Image(
label=" ",
type="pil",
format="png",
height=375,
)
t2i_file = gr.File(
label=" ",
visible=False,
)
with gr.Row():
t2i_used_seed = gr.Number(
label=" ",
interactive=False,
visible=False,
)
t2i_timing = gr.Textbox(
label=" ",
interactive=False,
visible=False,
)
gr.Examples(
examples=[
[
'A neon shop sign that reads "QWEN IMAGE 2.1", '
"rainy night, reflections on wet pavement",
False,
"16:9 — 1376x768",
],
[
"A capybara wearing a wizard hat, reading a book by "
"candlelight, oil painting",
False,
"1:1 — 1024x1024",
],
[
"A cute cartoon dragon sticker",
True,
"1:1 — 1024x1024",
],
[
"A glossy 3D icon of a paper plane, soft studio lighting",
True,
"1:1 — 1024x1024",
],
[
"A handwritten chalkboard menu in a Parisian cafe, "
"legible French text, warm morning light",
False,
"3:4 — 896x1200",
],
],
inputs=[
t2i_prompt,
t2i_transparent,
t2i_size,
],
outputs=[
t2i_image,
t2i_file,
t2i_used_seed,
t2i_timing,
],
fn=generate,
cache_examples=True,
cache_mode="lazy",
visible=False,
)
with gr.Tab(" "):
with gr.Row():
with gr.Column(scale=1):
edit_instruction = gr.Textbox(
label=" ",
lines=6,
)
edit_refs = gr.Gallery(
label=" ",
type="filepath",
columns=5,
height=344,
interactive=True,
)
edit_size = gr.Radio(
label=" ",
choices=list(EDIT_SIZE_PRESETS),
value=DEFAULT_EDIT_SIZE,
)
edit_run = gr.Button(
" ",
variant="primary",
)
with gr.Accordion(" ", open=False):
edit_steps = gr.Slider(
10,
50,
value=25,
step=1,
label=" ",
)
edit_cfg = gr.Slider(
1.0,
10.0,
value=1.1,
step=0.1,
label=" ",
)
edit_negative = gr.Textbox(
label=" ",
lines=2,
)
edit_seed = gr.Slider(
0,
MAX_SEED,
value=0,
step=1,
label=" ",
)
edit_random = gr.Checkbox(
label=" ",
value=True,
)
with gr.Column(scale=1):
edit_image = gr.Image(
label=" ",
type="pil",
format="png",
height=344,
)
edit_file = gr.File(
label=" ",
visible=False,
)
with gr.Row():
edit_used_seed = gr.Number(
label=" ",
interactive=False,
visible=False,
)
edit_timing = gr.Textbox(
label=" ",
interactive=False,
visible=False,
)
gr.Markdown(" ")
t2i_run.click(
fn=generate,
inputs=[
t2i_prompt,
t2i_transparent,
t2i_size,
t2i_steps,
t2i_cfg,
t2i_negative,
t2i_seed,
t2i_random,
],
outputs=[
t2i_image,
t2i_file,
t2i_used_seed,
t2i_timing,
],
api_name="generate",
)
t2i_prompt.submit(
fn=generate,
inputs=[
t2i_prompt,
t2i_transparent,
t2i_size,
t2i_steps,
t2i_cfg,
t2i_negative,
t2i_seed,
t2i_random,
],
outputs=[
t2i_image,
t2i_file,
t2i_used_seed,
t2i_timing,
],
api_name=False,
)
edit_run.click(
fn=edit,
inputs=[
edit_instruction,
edit_refs,
edit_size,
edit_steps,
edit_cfg,
edit_negative,
edit_seed,
edit_random,
],
outputs=[
edit_image,
edit_file,
edit_used_seed,
edit_timing,
],
api_name="edit",
)
if __name__ == "__main__":
demo.launch(mcp_server=False)