File size: 6,715 Bytes
611c02f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a4a3ff4
611c02f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a4a3ff4
 
611c02f
 
 
 
 
 
a4a3ff4
611c02f
a4a3ff4
611c02f
7f266e2
 
 
 
a4a3ff4
7f266e2
 
611c02f
 
a4a3ff4
611c02f
 
 
 
 
 
a4a3ff4
611c02f
 
 
 
 
a4a3ff4
611c02f
 
 
 
 
 
a4a3ff4
611c02f
 
 
 
 
 
 
 
 
 
 
 
a4a3ff4
611c02f
 
a4a3ff4
 
 
611c02f
 
 
 
 
 
 
 
0af7aa1
611c02f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a4a3ff4
611c02f
 
 
a4a3ff4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
611c02f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
"""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)