diff --git a/docs/.nav.yml b/docs/.nav.yml index 157ec09a1..caf2b7736 100644 --- a/docs/.nav.yml +++ b/docs/.nav.yml @@ -8,6 +8,7 @@ nav: - examples/README.md - Offline Inference: - Qwen2.5-Omni: user_guide/examples/offline_inference/qwen2_5_omni.md + - Qwen2.5-Image: user_guide/examples/offline_inference/qwen_image.md - Online Serving: user_guide/examples/online_serving/README.md - General: - usage/* diff --git a/examples/offline_inference/diffusion/qwen_image.py b/examples/offline_inference/diffusion/qwen_image.py deleted file mode 100644 index 24c43ca02..000000000 --- a/examples/offline_inference/diffusion/qwen_image.py +++ /dev/null @@ -1,20 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project - -import torch - -from vllm_omni.entrypoints.omni import Omni - -model_name = "Qwen/Qwen-Image" -prompt = "a cup of coffee on the table" -if __name__ == "__main__": - m = Omni( - model=model_name, - ) - image = m.generate( - prompt, - height=1024, - width=1024, - generator=torch.Generator(device="cuda").manual_seed(42), - ) - image[0].save("qwen_image_output.png") diff --git a/examples/offline_inference/qwen_image/README.md b/examples/offline_inference/qwen_image/README.md new file mode 100644 index 000000000..85d364568 --- /dev/null +++ b/examples/offline_inference/qwen_image/README.md @@ -0,0 +1,43 @@ +# Qwen-Image Offline Inference + +This folder provides two simple entrypoints for experimenting with `Qwen/Qwen-Image` using vLLM-Omni: + +- `text_to_image.py`: command-line script for single image generation. +- `web_demo.py`: lightweight Gradio UI for interactive prompt/seed/CFG exploration. + + +## Local CLI Usage + +```bash +python text_to_image.py \ + --prompt "a cup of coffee on the table" \ + --seed 42 \ + --cfg_scale 4.0 \ + --num_images_per_prompt 1 \ + --num_inference_steps 50 \ + --height 1024 \ + --width 1024 \ + --output outputs/coffee.png +``` + +Key arguments: + +- `--prompt`: text description (string). +- `--seed`: integer seed for deterministic sampling. +- `--cfg_scale`: true CFG scale (model-specific guidance strength). +- `--num_images_per_prompt`: number of images to generate per prompt (saves as `output`, `output_1`, ...). +- `--num_inference_steps`: diffusion sampling steps (more steps = higher quality, slower). +- `--height/--width`: output resolution (defaults 1024x1024). +- `--output`: path to save the generated PNG. + +> ℹ️ Qwen-Image currently publishes best-effort presets at `1328x1328`, `1664x928`, `928x1664`, `1472x1140`, `1140x1472`, `1584x1056`, and `1056x1584`. Adjust `--height/--width` accordingly for the most reliable outcomes. + +## Web UI Demo + +Launch the gradio demo: + +```bash +python gradio_demo.py --port 7862 +``` + +Then open `http://localhost:7862/` on your local browser to interact with the web UI. diff --git a/examples/offline_inference/qwen_image/gradio_demo.py b/examples/offline_inference/qwen_image/gradio_demo.py new file mode 100644 index 000000000..7906054a0 --- /dev/null +++ b/examples/offline_inference/qwen_image/gradio_demo.py @@ -0,0 +1,214 @@ +import argparse +from functools import lru_cache + +import gradio as gr +import torch + +from vllm_omni.entrypoints.omni import Omni + +ASPECT_RATIOS: dict[str, tuple[int, int]] = { + "1:1": (1328, 1328), + "16:9": (1664, 928), + "9:16": (928, 1664), + "4:3": (1472, 1140), + "3:4": (1140, 1472), + "3:2": (1584, 1056), + "2:3": (1056, 1584), +} +ASPECT_RATIO_CHOICES = [f"{ratio} ({w}x{h})" for ratio, (w, h) in ASPECT_RATIOS.items()] + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Gradio demo for Qwen-Image offline inference.") + parser.add_argument("--model", default="Qwen/Qwen-Image", help="Diffusion model name or local path.") + parser.add_argument( + "--height", + type=int, + default=1328, + help="Default image height (must match one of the supported presets).", + ) + parser.add_argument( + "--width", + type=int, + default=1328, + help="Default image width (must match one of the supported presets).", + ) + parser.add_argument("--default-prompt", default="a cup of coffee on the table", help="Initial prompt shown in UI.") + parser.add_argument("--default-seed", type=int, default=42, help="Initial seed shown in UI.") + parser.add_argument("--default-cfg-scale", type=float, default=4.0, help="Initial CFG scale shown in UI.") + parser.add_argument( + "--num_inference_steps", + type=int, + default=50, + help="Default number of denoising steps shown in the UI.", + ) + parser.add_argument("--ip", default="127.0.0.1", help="Host/IP for Gradio `launch`.") + parser.add_argument("--port", type=int, default=7862, help="Port for Gradio `launch`.") + parser.add_argument("--share", action="store_true", help="Share the Gradio demo publicly.") + args = parser.parse_args() + args.aspect_ratio_label = next( + (ratio for ratio, dims in ASPECT_RATIOS.items() if dims == (args.width, args.height)), + None, + ) + if args.aspect_ratio_label is None: + supported = ", ".join(f"{ratio} ({w}x{h})" for ratio, (w, h) in ASPECT_RATIOS.items()) + parser.error(f"Unsupported resolution {args.width}x{args.height}. Please pick one of: {supported}.") + return args + + +@lru_cache(maxsize=1) +def get_omni(model_name: str) -> Omni: + return Omni(model=model_name) + + +def build_demo(args: argparse.Namespace) -> gr.Blocks: + device = "cuda" + omni = get_omni(args.model) + + def run_inference( + prompt: str, + seed_value: float, + cfg_scale_value: float, + resolution_choice: str, + num_steps_value: float, + num_images_choice: float, + ): + if not prompt or not prompt.strip(): + raise gr.Error("Please enter a non-empty prompt.") + ratio_label = resolution_choice.split(" ", 1)[0] + if ratio_label not in ASPECT_RATIOS: + raise gr.Error(f"Unsupported aspect ratio: {ratio_label}") + width, height = ASPECT_RATIOS[ratio_label] + try: + seed = int(seed_value) + num_steps = int(num_steps_value) + num_images = int(num_images_choice) + except (TypeError, ValueError) as exc: + raise gr.Error("Seed, inference steps, and number of images must be valid integers.") from exc + if num_steps <= 0: + raise gr.Error("Inference steps must be a positive integer.") + if num_images not in {1, 2, 3, 4}: + raise gr.Error("Number of images must be 1, 2, 3, or 4.") + generator = torch.Generator(device=device).manual_seed(seed) + images = omni.generate( + prompt.strip(), + height=height, + width=width, + generator=generator, + true_cfg_scale=float(cfg_scale_value), + num_inference_steps=num_steps, + num_images_per_prompt=num_images, + num_outputs_per_prompt=num_images, + ) + return [img for img in images[:num_images]] + + with gr.Blocks( + title="vLLM-Omni Web Serving Demo", + css=""" + /* Left column button width */ + .left-column button { + width: 100%; + } + /* Right preview area: fixed height, hide unnecessary buttons */ + .fixed-image { + height: 660px; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + } + .fixed-image .duplicate-button, + .fixed-image .svelte-drgfj2 { + display: none !important; + } + /* Gallery container: fill available space and center content */ + #image-gallery { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + } + /* Gallery grid: center horizontally and vertically, set gap */ + #image-gallery .grid { + display: flex; + flex-wrap: wrap; + justify-content: center; + align-items: center; + align-content: center; + gap: 16px; + width: 100%; + height: 100%; + } + /* Gallery grid items: center content */ + #image-gallery .grid > div { + display: flex; + align-items: center; + justify-content: center; + } + /* Gallery images: limit max height, maintain aspect ratio */ + .fixed-image img { + max-height: 660px !important; + width: auto !important; + object-fit: contain; + } + """, + ) as demo: + gr.Markdown("# vLLM-Omni Web Serving Demo") + gr.Markdown(f"**Model:** {args.model}") + + with gr.Row(): + with gr.Column(scale=1, elem_classes="left-column"): + prompt_input = gr.Textbox( + label="Prompt", + value=args.default_prompt, + placeholder="Describe the image you want to generate...", + lines=5, + ) + seed_input = gr.Number(label="Seed", value=args.default_seed, precision=0) + cfg_input = gr.Number(label="CFG Scale", value=args.default_cfg_scale) + steps_input = gr.Number( + label="Inference Steps", + value=args.num_inference_steps, + precision=0, + minimum=1, + ) + aspect_dropdown = gr.Dropdown( + label="Aspect Ratio (W:H)", + choices=ASPECT_RATIO_CHOICES, + value=f"{args.aspect_ratio_label} ({ASPECT_RATIOS[args.aspect_ratio_label][0]}x{ASPECT_RATIOS[args.aspect_ratio_label][1]})", + ) + num_images = gr.Dropdown( + label="Number of images", + choices=["1", "2", "3", "4"], + value="1", + ) + generate_btn = gr.Button("Generate", variant="primary") + with gr.Column(scale=2, elem_classes="fixed-image"): + gallery = gr.Gallery( + label="Preview", + columns=2, + rows=2, + height=660, + allow_preview=True, + show_label=True, + elem_id="image-gallery", + ) + + generate_btn.click( + fn=run_inference, + inputs=[prompt_input, seed_input, cfg_input, aspect_dropdown, steps_input, num_images], + outputs=gallery, + ) + + return demo + + +def main(): + args = parse_args() + demo = build_demo(args) + demo.launch(server_name=args.ip, server_port=args.port, share=args.share) + + +if __name__ == "__main__": + main() diff --git a/examples/offline_inference/qwen_image/text_to_image.py b/examples/offline_inference/qwen_image/text_to_image.py new file mode 100644 index 000000000..e0a4fb8b2 --- /dev/null +++ b/examples/offline_inference/qwen_image/text_to_image.py @@ -0,0 +1,78 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project + +import argparse +from pathlib import Path + +import torch + +from vllm_omni.entrypoints.omni import Omni + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Generate an image with Qwen-Image.") + parser.add_argument("--model", default="Qwen/Qwen-Image", help="Diffusion model name or local path.") + parser.add_argument("--prompt", default="a cup of coffee on the table", help="Text prompt for image generation.") + parser.add_argument("--seed", type=int, default=42, help="Random seed for deterministic results.") + parser.add_argument( + "--cfg_scale", + type=float, + default=4.0, + help="True classifier-free guidance scale specific to Qwen-Image.", + ) + parser.add_argument("--height", type=int, default=1024, help="Height of generated image.") + parser.add_argument("--width", type=int, default=1024, help="Width of generated image.") + parser.add_argument( + "--output", + type=str, + default="qwen_image_output.png", + help="Path to save the generated image (PNG).", + ) + parser.add_argument( + "--num_images_per_prompt", + type=int, + default=1, + help="Number of images to generate for the given prompt.", + ) + parser.add_argument( + "--num_inference_steps", + type=int, + default=50, + help="Number of denoising steps for the diffusion sampler.", + ) + return parser.parse_args() + + +def main(): + args = parse_args() + device = "cuda" if torch.cuda.is_available() else "cpu" + generator = torch.Generator(device=device).manual_seed(args.seed) + + omni = Omni(model=args.model) + images = omni.generate( + args.prompt, + height=args.height, + width=args.width, + generator=generator, + true_cfg_scale=args.cfg_scale, + num_inference_steps=args.num_inference_steps, + num_images_per_prompt=args.num_images_per_prompt, + num_outputs_per_prompt=args.num_images_per_prompt, + ) + + output_path = Path(args.output) + output_path.parent.mkdir(parents=True, exist_ok=True) + suffix = output_path.suffix or ".png" + stem = output_path.stem or "qwen_image_output" + if args.num_images_per_prompt <= 1: + images[0].save(output_path) + print(f"Saved generated image to {output_path}") + else: + for idx, img in enumerate(images): + save_path = output_path.parent / f"{stem}_{idx}{suffix}" + img.save(save_path) + print(f"Saved generated image to {save_path}") + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index 498c959f5..037996e16 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,8 @@ dependencies = [ "resampy>=0.4.3", "diffusers==0.35.2", "accelerate==1.12.0", + "gradio==5.50", + "soundfile>=0.13.1" # "vllm==0.11.0", # TODO: fix the entrypoints overwrite problem ] @@ -48,8 +50,6 @@ dev = [ "isort>=5.12.0", "mypy>=1.0.0", "pre-commit>=3.0.0", - "gradio>=5.49.1", - "soundfile>=0.13.1" ] docs = [ diff --git a/vllm_omni/diffusion/models/qwen_image/pipeline_qwen_image.py b/vllm_omni/diffusion/models/qwen_image/pipeline_qwen_image.py index e2e6d6110..cbedc5c31 100644 --- a/vllm_omni/diffusion/models/qwen_image/pipeline_qwen_image.py +++ b/vllm_omni/diffusion/models/qwen_image/pipeline_qwen_image.py @@ -599,6 +599,9 @@ class QwenImagePipeline( num_inference_steps = req.num_inference_steps or num_inference_steps generator = req.generator or generator true_cfg_scale = req.true_cfg_scale or true_cfg_scale + req_num_outputs = getattr(req, "num_outputs_per_prompt", None) + if req_num_outputs and req_num_outputs > 0: + num_images_per_prompt = req_num_outputs # 1. check inputs # 2. encode prompts # 3. prepare latents and timesteps