angleforge / src /backends /zerogpu.py
eoinedge's picture
Upload src/backends/zerogpu.py with huggingface_hub
0af7aa1 verified
Raw
History Blame Contribute Delete
6.72 kB
"""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()
@spaces.GPU(duration=60)
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)