# SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project """ Example script for image editing with OmniGen2. python image_edit.py \ --image input.png \ --model "OmniGen2/OmniGen2" \ --prompt "Change the background to classroom." \ --negative-prompt "(((deformed))), blurry, over saturation, bad anatomy, disfigured, poorly drawn face, mutation, mutated, (extra_limb), (ugly), (poorly drawn hands), fused fingers, messy drawing, broken legs censor, censored, censor_bar" \ --num-inference-steps 50 \ --seed 0 \ --guidance-scale 5.0 \ --guidance-scale-2 2.0 \ --output outputs/image_edit.png \ --num-outputs-per-prompt 2 Note: For OmniGen2, `guidance_scale` works as `text_guidance_scale`, and `guidance_scale_2` works as `image_guidance_scale`. Example script for image editing with Qwen-Image-Edit. Usage (single image): python image_edit.py \ --image input.png \ --prompt "Let this mascot dance under the moon, surrounded by floating stars and poetic bubbles such as 'Be Kind'" \ --output output_image_edit.png \ --num-inference-steps 50 \ --cfg-scale 4.0 \ --guidance-scale 1.0 Usage (multiple images): python image_edit.py \ --image input1.png input2.png input3.png \ --prompt "Combine these images into a single scene" \ --output output_image_edit.png \ --num-inference-steps 50 \ --cfg-scale 4.0 \ --guidance-scale 1.0 Usage (with cache-dit acceleration): python image_edit.py \ --image input.png \ --prompt "Edit description" \ --cache-backend cache_dit \ --cache-dit-max-continuous-cached-steps 3 \ --cache-dit-residual-diff-threshold 0.24 \ --cache-dit-enable-taylorseer Usage (with tea_cache acceleration): python image_edit.py \ --image input.png \ --prompt "Edit description" \ --cache-backend tea_cache \ --tea-cache-rel-l1-thresh 0.25 Usage (layered): python image_edit.py \ --model "Qwen/Qwen-Image-Layered" \ --image input.png \ --prompt "" \ --output "layered" \ --num-inference-steps 50 \ --cfg-scale 4.0 \ --layers 4 \ --color-format "RGBA" Usage (with CFG Parallel): python image_edit.py \ --image input.png \ --prompt "Edit description" \ --cfg-parallel-size 2 \ --num-inference-steps 50 \ --cfg-scale 4.0 Usage (disable torch.compile): python image_edit.py \ --image input.png \ --prompt "Edit description" \ --enforce-eager \ --num-inference-steps 50 \ --cfg-scale 4.0 For more options, run: python image_edit.py --help """ import argparse import json import os import time from pathlib import Path from typing import Any import torch from PIL import Image from vllm_omni.diffusion.data import DiffusionParallelConfig from vllm_omni.diffusion.utils.param_utils import apply_declared_extra_args from vllm_omni.entrypoints.omni import Omni from vllm_omni.entrypoints.openai.stage_params import clone_sampling_params from vllm_omni.inputs.data import OmniDiffusionSamplingParams from vllm_omni.model_extras import ( build_image_to_image_prompt, get_extra_body_params, get_model_class_name, should_init_extra_args_for_non_diffusion_stages, ) from vllm_omni.platforms import current_omni_platform def parse_profiler_config(value: str) -> dict[str, Any]: try: config = json.loads(value) except json.JSONDecodeError as e: raise argparse.ArgumentTypeError(f"--profiler-config must be valid JSON: {e}") from e if not isinstance(config, dict): raise argparse.ArgumentTypeError("--profiler-config must be a JSON object") return config def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Edit an image with Qwen-Image-Edit.") parser.add_argument( "--model", default="Qwen/Qwen-Image-Edit", help=( "Diffusion model name or local path. " "For multiple image inputs, use Qwen/Qwen-Image-Edit-2509 or Qwen/Qwen-Image-Edit-2511" "which supports QwenImageEditPlusPipeline." ), ) parser.add_argument( "--image", type=str, nargs="+", required=True, help="Path(s) to input image file(s) (PNG, JPG, etc.). Can specify multiple images.", ) parser.add_argument( "--deploy-config", type=str, default=None, help=( "Path to a deploy YAML. Required for multi-stage image-edit pipelines " "whose deploy config is not auto-loaded." ), ) parser.add_argument( "--prompt", type=str, required=True, help="Text prompt describing the edit to make to the image.", ) parser.add_argument( "--negative-prompt", type=str, default=None, required=False, ) parser.add_argument( "--width", type=int, default=None, metavar="W", help="Output image width in pixels. Default: None (pipeline's default).", ) parser.add_argument( "--height", type=int, default=None, metavar="H", help="Output image height in pixels. Default: None (pipeline's default).", ) parser.add_argument( "--seed", type=int, default=0, help="Random seed for deterministic results.", ) parser.add_argument( "--cfg-scale", type=float, default=4.0, help=( "True classifier-free guidance scale (default: 4.0). Guidance scale as defined in Classifier-Free " "Diffusion Guidance. Classifier-free guidance is enabled by setting cfg_scale > 1 and providing " "a negative_prompt. Higher guidance scale encourages images closely linked to the text prompt, " "usually at the expense of lower image quality." ), ) parser.add_argument( "--guidance-scale", type=float, default=1.0, help=( "Guidance scale for guidance-distilled models (default: 1.0, disabled). " "Unlike classifier-free guidance (--cfg-scale), guidance-distilled models take the guidance scale " "directly as an input parameter. Enabled when guidance_scale > 1. Ignored when not using guidance-distilled models." ), ) parser.add_argument( "--guidance-scale-2", type=float, default=None, help="image guidance scale for image-to-image generation." ) parser.add_argument( "--extra-args", type=parse_profiler_config, default=None, help="JSON object copied to OmniDiffusionSamplingParams.extra_args, e.g. '{\"cfg_text_scale\": 4.0}'.", ) parser.add_argument( "--output", type=str, default="output_image_edit.png", help=("Path to save the edited image (PNG). Or prefix for Qwen-Image-Layered model save images(PNG)."), ) parser.add_argument( "--num-outputs-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.", ) parser.add_argument( "--cache-backend", type=str, default=None, choices=["cache_dit", "tea_cache"], help=( "Cache backend to use for acceleration. " "Options: 'cache_dit' (DBCache + SCM + TaylorSeer), 'tea_cache' (Timestep Embedding Aware Cache). " "Default: None (no cache acceleration)." ), ) parser.add_argument( "--ulysses-degree", type=int, default=1, help="Number of GPUs used for ulysses sequence parallelism.", ) parser.add_argument( "--ulysses-mode", type=str, default="strict", choices=["strict", "advanced_uaa"], help="Ulysses sequence-parallel mode: 'strict' (divisibility required) or 'advanced_uaa' (UAA).", ) parser.add_argument( "--ring-degree", type=int, default=1, help="Number of GPUs used for ring sequence parallelism.", ) parser.add_argument( "--tensor-parallel-size", type=int, default=1, help="Number of GPUs used for tensor parallelism (TP) inside the DiT.", ) parser.add_argument( "--enable-expert-parallel", action="store_true", help="Enable expert parallelism for MoE layers.", ) parser.add_argument("--layers", type=int, default=4, help="Number of layers to decompose the input image into.") parser.add_argument( "--resolution", type=int, default=None, help="Bucket in (640, 1024) to determine the condition and output resolution. If width and height are not provided, this will be set to default 640.", ) parser.add_argument( "--color-format", type=str, default="RGB", help="For Qwen-Image-Layered, set to RGBA.", ) # Cache-DiT specific parameters parser.add_argument( "--cache-dit-fn-compute-blocks", type=int, default=1, help="[cache-dit] Number of forward compute blocks. Optimized for single-transformer models.", ) parser.add_argument( "--cache-dit-bn-compute-blocks", type=int, default=0, help="[cache-dit] Number of backward compute blocks.", ) parser.add_argument( "--cache-dit-max-warmup-steps", type=int, default=4, help="[cache-dit] Maximum warmup steps (works for few-step models).", ) parser.add_argument( "--cache-dit-residual-diff-threshold", type=float, default=0.24, help="[cache-dit] Residual diff threshold. Higher values enable more aggressive caching.", ) parser.add_argument( "--cache-dit-max-continuous-cached-steps", type=int, default=3, help="[cache-dit] Maximum continuous cached steps to prevent precision degradation.", ) parser.add_argument( "--cache-dit-enable-taylorseer", action="store_true", default=False, help="[cache-dit] Enable TaylorSeer acceleration (not suitable for few-step models).", ) parser.add_argument( "--cache-dit-taylorseer-order", type=int, default=1, help="[cache-dit] TaylorSeer polynomial order.", ) parser.add_argument( "--cache-dit-scm-steps-mask-policy", type=str, default=None, choices=[None, "slow", "medium", "fast", "ultra"], help="[cache-dit] SCM mask policy: None (disabled), slow, medium, fast, ultra.", ) parser.add_argument( "--cache-dit-scm-steps-policy", type=str, default="dynamic", choices=["dynamic", "static"], help="[cache-dit] SCM steps policy: dynamic or static.", ) # TeaCache specific parameters parser.add_argument( "--tea-cache-rel-l1-thresh", type=float, default=0.2, help="[tea_cache] Threshold for accumulated relative L1 distance.", ) parser.add_argument( "--cfg-parallel-size", type=int, default=1, choices=[1, 2, 3], help="Number of GPUs used for classifier free guidance parallel size (max 3 branches).", ) parser.add_argument( "--enforce-eager", action="store_true", default=None, help=( "Disable torch.compile and force eager execution. Left unset (None) " "so it is only forwarded when explicitly given; " "otherwise the per-stage deploy YAML value wins." ), ) parser.add_argument( "--vae-use-slicing", action="store_true", help="Enable VAE slicing for memory optimization.", ) parser.add_argument( "--vae-use-tiling", action="store_true", help="Enable VAE tiling for memory optimization.", ) parser.add_argument( "--enable-cpu-offload", action="store_true", help="Enable CPU offloading for diffusion models.", ) parser.add_argument( "--enable-layerwise-offload", action="store_true", help="Enable layerwise (blockwise) offloading on DiT modules.", ) parser.add_argument( "--vae-patch-parallel-size", type=int, default=1, help="Number of GPUs used for VAE patch/tile parallelism (decode).", ) parser.add_argument( "--use-hsdp", action="store_true", help="Enable HSDP (Hybrid Sharded Data Parallel) for diffusion models.", ) parser.add_argument( "--hsdp-shard-size", type=int, default=1, help="Number of GPUs to shard weights across for HSDP.", ) parser.add_argument( "--hsdp-replicate-size", type=int, default=1, help="Number of HSDP replica groups.", ) parser.add_argument( "--enable-diffusion-pipeline-profiler", action="store_true", help="Enable diffusion pipeline profiler to display stage durations.", ) parser.add_argument( "--profiler-config", type=parse_profiler_config, default=None, help='JSON profiler config for torch/cuda profiling, e.g. \'{"profiler":"torch","torch_profiler_dir":"./perf"}\'.', ) return parser.parse_args() def main(): args = parse_args() if args.resolution and (args.width or args.height): raise ValueError("--resolution and --width/--height cannot be specified together") if args.width is not None and args.width <= 0: raise ValueError("--width must be a positive integer") if args.height is not None and args.height <= 0: raise ValueError("--height must be a positive integer") if not args.width and not args.height and not args.resolution: args.resolution = 640 # Validate input images exist and load them input_images = [] for image_path in args.image: if not os.path.exists(image_path): raise FileNotFoundError(f"Input image not found: {image_path}") img = Image.open(image_path).convert(args.color_format) input_images.append(img) # Use single image or list based on number of inputs if len(input_images) == 1: input_image = input_images[0] else: input_image = input_images generator = torch.Generator(device=current_omni_platform.device_type).manual_seed(args.seed) parallel_config = DiffusionParallelConfig( ulysses_degree=args.ulysses_degree, ring_degree=args.ring_degree, cfg_parallel_size=args.cfg_parallel_size, tensor_parallel_size=args.tensor_parallel_size, enable_expert_parallel=args.enable_expert_parallel, ) # Configure cache based on backend type cache_config = None if args.cache_backend == "cache_dit": # cache-dit configuration: Hybrid DBCache + SCM + TaylorSeer cache_config = { "Fn_compute_blocks": args.cache_dit_fn_compute_blocks, "Bn_compute_blocks": args.cache_dit_bn_compute_blocks, "max_warmup_steps": args.cache_dit_max_warmup_steps, "residual_diff_threshold": args.cache_dit_residual_diff_threshold, "max_continuous_cached_steps": args.cache_dit_max_continuous_cached_steps, "enable_taylorseer": args.cache_dit_enable_taylorseer, "taylorseer_order": args.cache_dit_taylorseer_order, "scm_steps_mask_policy": args.cache_dit_scm_steps_mask_policy, "scm_steps_policy": args.cache_dit_scm_steps_policy, } elif args.cache_backend == "tea_cache": # TeaCache configuration cache_config = { "rel_l1_thresh": args.tea_cache_rel_l1_thresh, # Note: coefficients will use model-specific defaults based on model_type } # Initialize Omni with appropriate pipeline omni_kwargs: dict[str, Any] = dict( model=args.model, enable_layerwise_offload=args.enable_layerwise_offload, vae_use_slicing=args.vae_use_slicing, vae_use_tiling=args.vae_use_tiling, cache_backend=args.cache_backend, cache_config=cache_config, parallel_config=parallel_config, enable_cpu_offload=args.enable_cpu_offload, enable_diffusion_pipeline_profiler=args.enable_diffusion_pipeline_profiler, profiler_config=args.profiler_config, ) if args.enforce_eager is not None: omni_kwargs["enforce_eager"] = args.enforce_eager if args.deploy_config: omni_kwargs["deploy_config"] = args.deploy_config omni = Omni(**omni_kwargs) model_class_name = get_model_class_name(omni) declared_extra_body_params = get_extra_body_params(model_class_name) print("Pipeline loaded") profiler_enabled = args.profiler_config is not None # Time profiling for generation print(f"\n{'=' * 60}") print("Generation Configuration:") print(f" Model: {args.model}") print(f" Inference steps: {args.num_inference_steps}") print(f" Cache backend: {args.cache_backend if args.cache_backend else 'None (no acceleration)'}") if args.height is not None or args.width is not None: print(f" Output size: {args.width or 'auto'}x{args.height or 'auto'}") if isinstance(input_image, list): print(f" Number of input images: {len(input_image)}") for idx, img in enumerate(input_image): print(f" Image {idx + 1} size: {img.size}") else: print(f" Input image size: {input_image.size}") print( f" Parallel configuration: ulysses_degree={args.ulysses_degree}, ring_degree={args.ring_degree}, cfg_parallel_size={args.cfg_parallel_size}, tensor_parallel_size={args.tensor_parallel_size}, enable_expert_parallel: {args.enable_expert_parallel}" ) print(f"{'=' * 60}\n") generation_start = time.perf_counter() if profiler_enabled: print("[Profiler] Starting profiling...") omni.start_profile() prompt_dict = build_image_to_image_prompt( model_class_name=model_class_name, prompt=args.prompt, negative_prompt=args.negative_prompt, input_image=input_image, height=args.height, width=args.width, ) extra_args_from_cli = dict(args.extra_args or {}) if args.negative_prompt is not None: extra_args_from_cli.setdefault("negative_prompt", args.negative_prompt) diffusion_params = OmniDiffusionSamplingParams( generator=generator, true_cfg_scale=args.cfg_scale, guidance_scale=args.guidance_scale, guidance_scale_2=args.guidance_scale_2, num_inference_steps=args.num_inference_steps, num_outputs_per_prompt=args.num_outputs_per_prompt, layers=args.layers, resolution=args.resolution, height=args.height, width=args.width, ) if declared_extra_body_params: apply_declared_extra_args( diffusion_params, declared_extra_body_params, extra_args_from_cli, ) else: diffusion_params.extra_args.update({k: v for k, v in extra_args_from_cli.items() if v is not None}) # Build per-stage sampling params for multi-stage models init_non_diffusion = should_init_extra_args_for_non_diffusion_stages( model_class_name, ) defaults = list(omni.default_sampling_params_list or []) sampling_params_list = [clone_sampling_params(p) for p in defaults] if not sampling_params_list: sampling_params_list = [diffusion_params] diffusion_replaced = False for idx, params in enumerate(sampling_params_list): if isinstance(params, OmniDiffusionSamplingParams): merged_extra = dict(getattr(params, "extra_args", {}) or {}) merged_extra.update(diffusion_params.extra_args) diffusion_params.extra_args = merged_extra sampling_params_list[idx] = diffusion_params diffusion_replaced = True elif init_non_diffusion and hasattr(params, "extra_args"): if params.extra_args is None: params.extra_args = {} if not diffusion_replaced and len(sampling_params_list) == 1: sampling_params_list = [diffusion_params] outputs = omni.generate(prompt_dict, sampling_params_list=sampling_params_list) generation_end = time.perf_counter() generation_time = generation_end - generation_start # Print profiling results print(f"Total generation time: {generation_time:.4f} seconds ({generation_time * 1000:.2f} ms)") if profiler_enabled: print("\n[Profiler] Stopping profiler and collecting results...") profile_results = omni.stop_profile() if profile_results and isinstance(profile_results, dict): traces = profile_results.get("traces", []) print("\n" + "=" * 60) print("PROFILING RESULTS:") for rank, trace in enumerate(traces): print(f"\nRank {rank}:") if trace: print(f" • Trace: {trace}") if not traces: print(" No traces collected.") print("=" * 60) else: print("[Profiler] No valid profiling data returned.") if not outputs: raise ValueError("No output generated from omni.generate()") images = None for output in outputs: images = getattr(output, "images", None) if images: break req_out = getattr(output, "request_output", None) images = getattr(req_out, "images", None) if req_out is not None else None if images: break if not images: raise ValueError("No images found in request_output") # Save output image(s) 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 "output_image_edit" # Handle layered output (each image may be a list of layers) if args.num_outputs_per_prompt <= 1: img = images[0] # Check if this is a layered output (list of images) if isinstance(img, list): for sub_idx, sub_img in enumerate(img): save_path = output_path.parent / f"{stem}_{sub_idx}{suffix}" sub_img.save(save_path) print(f"Saved edited image to {os.path.abspath(save_path)}") else: img.save(output_path) print(f"Saved edited image to {os.path.abspath(output_path)}") else: for idx, img in enumerate(images): # Check if this is a layered output (list of images) if isinstance(img, list): for sub_idx, sub_img in enumerate(img): save_path = output_path.parent / f"{stem}_{idx}_{sub_idx}{suffix}" sub_img.save(save_path) print(f"Saved edited image to {os.path.abspath(save_path)}") else: save_path = output_path.parent / f"{stem}_{idx}{suffix}" img.save(save_path) print(f"Saved edited image to {os.path.abspath(save_path)}") if __name__ == "__main__": main()