Spaces:
Running on Zero
Running on Zero
| """ZeroGPU backend for Qwen Image Edit (real multi-angle LoRA edits). | |
| On Hugging Face **ZeroGPU** Spaces a real GPU is attached only for the duration | |
| of a function decorated with ``@spaces.GPU``. This module therefore: | |
| 1. Loads the Qwen-Image-Edit-2509 pipeline + Rapid-AIO transformer + dx8152's | |
| multiple-angles LoRA **at import time** (guarded by the ``SPACES_ZERO_GPU`` | |
| env var so it only happens on an actual ZeroGPU Space). | |
| 2. Exposes a module-level ``@spaces.GPU`` inference function so ZeroGPU can | |
| schedule it on a GPU. | |
| Mirrors ``linoyts/Qwen-Image-Edit-Angles``. On any non-ZeroGPU machine this | |
| module stays inert and ``ZeroGpuQwenBackend.prepare()`` raises, letting | |
| ``select_backend`` fall through to the next option. | |
| """ | |
| from __future__ import annotations | |
| import os | |
| import traceback | |
| from PIL import Image | |
| from ..config import ( | |
| ANGLES_LORA_REPO, | |
| ANGLES_LORA_WEIGHT, | |
| QWEN_BASE_MODEL, | |
| QWEN_RAPID_TRANSFORMER, | |
| ) | |
| from ..images import fit_image | |
| from .base import ImageEditBackend | |
| def on_zerogpu() -> bool: | |
| return str(os.environ.get("SPACES_ZERO_GPU", "")).lower() in ("1", "true", "yes") | |
| # Populated at import time when running on ZeroGPU. | |
| _PIPE = None | |
| _LOAD_ERROR: Exception | None = None | |
| _LOAD_ERROR_TB: str = "" | |
| _LOAD_STEP: str = "not started" | |
| run_zero_edit = None # module-level @spaces.GPU function (or None off-ZeroGPU) | |
| _LORA_SCALE = 1.25 | |
| def _load_pipeline(): | |
| """Load and fuse the Qwen Image Edit pipeline. Runs once at import.""" | |
| global _PIPE, _LOAD_ERROR, _LOAD_ERROR_TB, _LOAD_STEP | |
| try: | |
| _LOAD_STEP = "import torch" | |
| import torch | |
| # Vendored Qwen classes (mirrors linoyts/Qwen-Image-Edit-Angles). These | |
| # depend on bleeding-edge diffusers internals, so they are shipped in | |
| # the repo rather than imported from a released ``diffusers``. | |
| _LOAD_STEP = "import vendored qwenimage classes" | |
| from qwenimage.pipeline_qwenimage_edit_plus import QwenImageEditPlusPipeline | |
| from qwenimage.transformer_qwenimage import QwenImageTransformer2DModel | |
| dtype = torch.bfloat16 | |
| _LOAD_STEP = f"load transformer {QWEN_RAPID_TRANSFORMER}" | |
| transformer = QwenImageTransformer2DModel.from_pretrained( | |
| QWEN_RAPID_TRANSFORMER, | |
| subfolder="transformer", | |
| torch_dtype=dtype, | |
| device_map="cuda", | |
| ) | |
| _LOAD_STEP = f"load pipeline {QWEN_BASE_MODEL}" | |
| pipe = QwenImageEditPlusPipeline.from_pretrained( | |
| QWEN_BASE_MODEL, | |
| transformer=transformer, | |
| torch_dtype=dtype, | |
| ).to("cuda") | |
| _LOAD_STEP = f"load LoRA {ANGLES_LORA_REPO}/{ANGLES_LORA_WEIGHT}" | |
| pipe.load_lora_weights( | |
| ANGLES_LORA_REPO, | |
| weight_name=ANGLES_LORA_WEIGHT, | |
| adapter_name="angles", | |
| ) | |
| pipe.set_adapters(["angles"], adapter_weights=[1.0]) | |
| _LOAD_STEP = "fuse LoRA" | |
| pipe.fuse_lora(adapter_names=["angles"], lora_scale=_LORA_SCALE) | |
| pipe.unload_lora_weights() | |
| # Optional AOT-compiled transformer blocks for faster inference. | |
| try: | |
| import spaces | |
| spaces.aoti_blocks_load(pipe.transformer, "zerogpu-aoti/Qwen-Image", variant="fa3") | |
| except Exception as exc: # noqa: BLE001 - optimisation only | |
| print(f"[zerogpu] AOTI blocks not loaded ({exc}); continuing without.") | |
| _PIPE = pipe | |
| _LOAD_STEP = "loaded" | |
| except Exception as exc: # noqa: BLE001 - surfaced via prepare() | |
| _LOAD_ERROR = exc | |
| _LOAD_ERROR_TB = traceback.format_exc() | |
| print(f"[zerogpu] Pipeline load failed at [{_LOAD_STEP}]: {exc}") | |
| print(_LOAD_ERROR_TB) | |
| if on_zerogpu(): | |
| try: | |
| import spaces # noqa: WPS433 | |
| _load_pipeline() | |
| def run_zero_edit( # noqa: F811 - intentional module-level assignment | |
| image: Image.Image, | |
| prompt: str, | |
| seed: int, | |
| num_inference_steps: int, | |
| true_guidance_scale: float, | |
| width: int, | |
| height: int, | |
| ) -> Image.Image: | |
| import torch | |
| if _PIPE is None: | |
| raise RuntimeError("Qwen pipeline unavailable on ZeroGPU.") | |
| generator = torch.Generator(device="cuda").manual_seed(int(seed)) | |
| return _PIPE( | |
| image=[image], | |
| prompt=prompt, | |
| width=width, | |
| height=height, | |
| num_inference_steps=num_inference_steps, | |
| generator=generator, | |
| true_cfg_scale=true_guidance_scale, | |
| num_images_per_prompt=1, | |
| ).images[0] | |
| except Exception as exc: # noqa: BLE001 | |
| _LOAD_ERROR = exc | |
| _LOAD_ERROR_TB = traceback.format_exc() | |
| print(f"[zerogpu] spaces unavailable: {exc}") | |
| def diagnostics() -> str: | |
| """Human-readable status of the ZeroGPU pipeline (for surfacing in the UI).""" | |
| lines = [ | |
| f"on_zerogpu: {on_zerogpu()}", | |
| f"SPACES_ZERO_GPU env: {os.environ.get('SPACES_ZERO_GPU')!r}", | |
| f"pipeline loaded: {_PIPE is not None}", | |
| f"run_zero_edit ready: {run_zero_edit is not None}", | |
| f"last load step: {_LOAD_STEP}", | |
| ] | |
| if _LOAD_ERROR is not None: | |
| lines.append(f"load error: {type(_LOAD_ERROR).__name__}: {_LOAD_ERROR}") | |
| if _LOAD_ERROR_TB: | |
| lines.append("traceback:\n" + _LOAD_ERROR_TB.strip()) | |
| return "\n".join(lines) | |
| class ZeroGpuQwenBackend(ImageEditBackend): | |
| """Runs the real Qwen multi-angle pipeline on a ZeroGPU Space.""" | |
| source = "zerogpu_qwen_image_edit" | |
| def __init__(self, image_size: int) -> None: | |
| self.image_size = image_size | |
| def prepare(self) -> None: | |
| if not on_zerogpu(): | |
| raise RuntimeError("Not running on a ZeroGPU Space.") | |
| if _LOAD_ERROR is not None: | |
| raise RuntimeError(f"ZeroGPU pipeline failed to load: {_LOAD_ERROR}") | |
| if run_zero_edit is None or _PIPE is None: | |
| raise RuntimeError("ZeroGPU pipeline not initialised.") | |
| def edit( | |
| self, | |
| image: Image.Image, | |
| prompt: str, | |
| seed: int, | |
| num_inference_steps: int, | |
| true_guidance_scale: float, | |
| ) -> Image.Image: | |
| base = fit_image(image.convert("RGB"), self.image_size) | |
| if not prompt.strip(): | |
| return base | |
| result = run_zero_edit( | |
| base, | |
| prompt, | |
| int(seed), | |
| int(num_inference_steps), | |
| float(true_guidance_scale), | |
| base.width, | |
| base.height, | |
| ) | |
| return fit_image(result.convert("RGB"), self.image_size) | |