Spaces:
Running on Zero
Running on Zero
Download app.py from Dekonstruktio/Visual: direct link, hf CLI and curl.
- Browser
- Download file 19.7 kB
-
https://huggingface.co/spaces/Dekonstruktio/Visual/resolve/main/app.py
- Command line
-
hf download hf://spaces/Dekonstruktio/Visual/app.py
-
curl -L -o app.py https://huggingface.co/spaces/Dekonstruktio/Visual/resolve/main/app.py
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, | |
| ) | |
| 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", | |
| ) | |
| 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) | |