Coverage for wrapper/qwenimageedit/wrapper_qwenimageedit.py: 88%
120 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-09 04:47 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-09 04:47 +0000
1import logging
2import os
3import sys
4import random
6from typing import override
7from typing import Optional
8from typing import Dict
9from typing import Any
11from PIL import Image
13import torch
14from torch import inference_mode
16from image_utils import base64_to_img
17from wrapper_model import ModelGeneration
19from diffusers import QwenImageEditPipeline
21from xfuser.config import EngineConfig
24class QwenImageEditGeneration(ModelGeneration):
25 """Handle image generation using the QwenImageEdit model."""
26 HF_MODEL_NAME = "Qwen/Qwen-Image-Edit"
28 def __init__(
29 self,
30 model_name: str = "qwenimageedit",
31 engine_config: EngineConfig = None,
32 param_dtype: torch.dtype = torch.bfloat16,
33 ) -> None:
34 super().__init__(model_name)
36 self.engine_config = engine_config
37 if self.engine_config is not None:
38 self.torch_compile = self.engine_config.runtime_config.use_torch_compile
39 self.param_dtype = param_dtype
41 # Model components
42 self.pipeline: Optional[QwenImageEditPipeline] = None
44 def __del__(self) -> None:
45 """Clean models."""
46 if self.pipeline is not None:
47 self.pipeline = None
49 def init_parallelism(self) -> None:
50 """Initialize distributed settings for multi-GPU setups."""
51 self.load_timer.start("torch_dist")
53 self.rank = int(os.getenv("RANK", 0))
54 self.local_rank = int(os.getenv("LOCAL_RANK", 0))
55 self.world_size = int(os.getenv("WORLD_SIZE", 1))
57 self.device_id = self.local_rank
58 self.device = torch.device(f"cuda:{self.device_id}")
60 torch.cuda.set_device(self.local_rank)
62 if self.world_size > 1:
63 logging.warning("Qwen Image Edit is not optimized for multi-GPU setups (yet).")
64 self.world_size = 1
66 self.load_timer.end("torch_dist")
68 def init_model_parallelism(self) -> None:
69 """Qwen Image Edit does not support parallelism yet."""
70 if self.world_size > 1:
71 logging.warning("Parallelism not supported.")
73 def load_model(self) -> None:
74 """Load the Qwen Image Edit model from Hugging Face."""
75 assert torch.cuda.is_available()
77 self.load_timer.start("pipeline")
78 self.pipeline = QwenImageEditPipeline.from_pretrained(
79 pretrained_model_name_or_path=self.HF_MODEL_NAME,
80 torch_dtype=self.param_dtype,
81 )
82 self.pipeline = self.pipeline.to(self.device) # type: ignore[union-attr]
83 self.load_timer.end("pipeline")
85 def model_compile(self) -> None:
86 """Compile the model using torch.compile if enabled."""
87 if not self.torch_compile:
88 return
90 self.load_timer.start("dit_compile")
91 torch._inductor.config.reorder_for_compute_comm_overlap = True
92 assert self.pipeline is not None
93 self.pipeline.transformer = torch.compile( # type: ignore[attr-defined]
94 self.pipeline.transformer, # type: ignore[attr-defined]
95 mode="max-autotune-no-cudagraphs"
96 )
97 self.load_timer.end("dit_compile")
99 def _assert_model_init(self) -> None:
100 """Check if the model has been initialized."""
101 super()._assert_model_init()
102 assert self.pipeline is not None
104 def _get_vae_scale_factor(self) -> int:
105 """Return the VAE scale factor from the pipeline, raising if unavailable."""
106 if not self.pipeline:
107 raise ValueError("Pipeline not initialized.")
108 vae_scale_factor = getattr(self.pipeline, "vae_scale_factor", None)
109 if vae_scale_factor is None:
110 raise ValueError("Pipeline does not have vae_scale_factor.")
111 return vae_scale_factor
113 def _assert_args(
114 self,
115 height: int,
116 width: int,
117 ) -> None:
118 """Check if the image size is supported for the current parallelism setting."""
119 vae_scale_factor = self._get_vae_scale_factor()
120 height_latent = height // vae_scale_factor
121 width_latent = width // vae_scale_factor
122 img_latent_shape = (height_latent // 2) * (width_latent // 2)
123 if img_latent_shape % self.world_size != 0:
124 raise ValueError(f"{height}x{width} not supported for {self.world_size} GPUs.")
126 @inference_mode()
127 async def warmup(self) -> None:
128 """Warmup the model with a dummy generation."""
129 logging.info(f"[{self.rank}] Warmup for Qwen Image Edit generation.")
130 empty_img = Image.new("RGB", (512, 512), (255, 255, 255))
131 await self.generate(
132 empty_img,
133 width=1280,
134 height=800,
135 prompt="A warmup image to initialize the model.",
136 neg_prompt="",
137 sampling_steps=5) # It needs at least 5 steps to warm up properly
139 @override
140 @inference_mode()
141 async def generate(
142 self,
143 img: Image.Image,
144 height: int,
145 width: int,
146 prompt: str,
147 neg_prompt: str = "",
148 sampling_steps: int = 25, # 10
149 seed: Optional[int] = None,
150 job_id: Optional[str] = None,
151 ) -> Image.Image:
152 """Generate an image using QwenImage."""
153 gen_timer = self._new_gen_timer(job_id)
155 self._assert_model_init()
156 self._assert_args(height, width)
157 assert self.pipeline is not None
159 self.running = True # Mark running to avoid concurrent calls
161 try:
162 if seed is None or seed < 0:
163 seed = random.randint(0, sys.maxsize)
164 seed_g = torch.Generator(device=self.device)
165 seed_g.manual_seed(seed)
167 def callback_gen_timer(
168 pipeline: QwenImageEditPipeline,
169 step: int,
170 timestep: int,
171 callback_kwargs: dict
172 ) -> dict:
173 gen_timer.end(f"step_{step:03d}")
174 if step < sampling_steps - 1:
175 gen_timer.start(f"step_{step + 1:03d}")
176 self.check_interrupted()
177 return callback_kwargs
179 gen_timer.start(f"step_{0:03d}")
180 output = self.pipeline( # type: ignore[operator]
181 image=img,
182 height=height,
183 width=width,
184 prompt=prompt,
185 negative_prompt=neg_prompt,
186 num_inference_steps=sampling_steps,
187 output_type="pil",
188 generator=seed_g,
189 callback_on_step_end=callback_gen_timer,
190 )
192 if not output or len(output.images) != 1:
193 raise ValueError(f"Expected 1 image, but got {len(output.images)} images")
194 image = output.images[0]
195 return image
196 finally:
197 self.running = False
198 gen_timer.end("total")
200 async def get_rest_args(self, data_json: Dict[str, str]) -> Dict[str, Any]:
201 """Parse and validate REST API arguments for Qwen Image Edit generation."""
202 if data_json is None:
203 raise ValueError("Missing JSON body")
204 img_base64 = data_json.get("img", None)
205 if img_base64 is None:
206 raise ValueError("Missing 'img' parameter")
207 img = base64_to_img(img_base64)
208 prompt = data_json.get("prompt", None)
209 if prompt is None:
210 raise ValueError("Missing 'prompt' parameter")
211 neg_prompt = data_json.get("neg_prompt", "")
212 height = int(data_json.get("height", 480))
213 width = int(data_json.get("width", 640))
214 steps = int(data_json.get("sampling_steps", 20))
215 return {
216 "task": self.model_name,
217 "args": {
218 "img": img,
219 "prompt": prompt,
220 "neg_prompt": neg_prompt,
221 "height": height,
222 "width": width,
223 "sampling_steps": steps,
224 }
225 }