File size: 6,820 Bytes
5ddd413
 
 
 
 
a0e2b41
 
5ddd413
 
 
a0e2b41
5ddd413
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e35ce27
 
 
 
 
 
 
 
 
 
 
 
fd34f8f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5ddd413
e35ce27
5ddd413
 
 
 
 
 
 
e35ce27
 
5ddd413
 
 
 
 
 
 
 
 
a0e2b41
5ddd413
 
 
a0e2b41
5ddd413
 
 
 
 
 
 
 
 
 
 
 
fd34f8f
 
5ddd413
 
 
 
 
 
 
 
 
 
fd34f8f
5ddd413
 
 
 
 
fd34f8f
5ddd413
 
 
 
 
 
 
e35ce27
5ddd413
e35ce27
 
 
 
 
5ddd413
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a0e2b41
 
 
 
 
 
 
5ddd413
 
 
 
 
a0e2b41
5ddd413
a0e2b41
 
5ddd413
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
195
196
197
198
199
"""
Skybox generator: text β†’ 2:1 equirectangular image (Stable Diffusion, local).
Uses FP16 to reduce VRAM. Output 1024x512 or 2048x1024.
"""

from __future__ import annotations

import os
import time
from pathlib import Path
from typing import Callable

import torch

# Default: v1.5 works without license acceptance. Use SD_MODEL_ID to prefer SD 2.1.
DEFAULT_MODEL_ID = "runwayml/stable-diffusion-v1-5"
FALLBACK_MODEL_ID = "runwayml/stable-diffusion-v1-5"  # Same; alternate if primary fails


def get_device() -> str:
    return "cuda" if torch.cuda.is_available() else "cpu"


def _is_complete_sd_dir(path: Path) -> bool:
    """True if path looks like a complete Stable Diffusion pipeline (has unet weights)."""
    if not path.is_dir():
        return False
    unet = path / "unet"
    if not unet.is_dir():
        return False
    return any(
        (unet / f).exists()
        for f in ("diffusion_pytorch_model.safetensors", "diffusion_pytorch_model.bin")
    )


def _default_local_weights_dir() -> str | None:
    """First complete SD folder under weights/ (sd-v1-5 or stable-diffusion-2-1-base)."""
    try:
        root = Path(__file__).resolve().parent.parent
        for name in ("sd-v1-5", "stable-diffusion-2-1-base"):
            local = root / "weights" / name
            if _is_complete_sd_dir(local):
                return str(local)
        return None
    except Exception:
        return None


def _get_hf_token():
    """Token for Hugging Face Hub. On Spaces, set HF_TOKEN in Settings β†’ Variables and secrets."""
    token = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")
    if token:
        return token
    try:
        from huggingface_hub import get_token
        return get_token()
    except Exception:
        return None


# Message shown when Hub returns 403 (missing/invalid token or gated model).
HF_403_MESSAGE = (
    "403 Forbidden from Hugging Face Hub. "
    "Add HF_TOKEN in this Space: Settings β†’ Variables and secrets β†’ New secret: HF_TOKEN (get a token at huggingface.co/settings/tokens, read access). "
    "If the model is gated, accept its license on the model page first, then restart the Space."
)


def _raise_if_403(err: Exception) -> None:
    """Re-raise with a clear message if the error is a 403 from the Hub."""
    if getattr(err, "response", None) is not None:
        status = getattr(err.response, "status_code", None)
        if status == 403:
            raise RuntimeError(HF_403_MESSAGE) from err
    if "403" in str(err).lower() or "forbidden" in str(err).lower():
        raise RuntimeError(HF_403_MESSAGE) from err


def _resolve_model_path_and_token():
    """Use local path if set or default weights/ folder exists, else Hub id. Token from HF_TOKEN or huggingface_hub."""
    local = os.environ.get("SD_MODEL_PATH", "").strip()
    if local and os.path.isdir(local):
        return local, None
    default_local = _default_local_weights_dir()
    if default_local:
        return default_local, None
    model_id = os.environ.get("SD_MODEL_ID", DEFAULT_MODEL_ID)
    token = _get_hf_token()
    return model_id, token or True


def generate_skybox(
    prompt: str,
    output_dir: str = "outputs",
    width: int = 1024,
    height: int = 512,
    seed: int | None = None,
    model_id: str | None = None,
    progress_callback: Callable[[int, int], None] | None = None,
) -> tuple[str, float, float]:
    """
    Generate a 2:1 equirectangular skybox image from a text prompt.
    progress_callback(step, total_steps) is called each denoising step if provided.
    Returns (path_to_image, inference_time_sec, peak_vram_mb).
    """
    from diffusers import StableDiffusionPipeline

    device = get_device()
    dtype = torch.float16 if device == "cuda" else torch.float32

    Path(output_dir).mkdir(parents=True, exist_ok=True)

    pretrained, token = _resolve_model_path_and_token()
    load_id = model_id or pretrained
    local_only = os.path.isdir(load_id)
    # Use explicit token only (no token=True) so we don't rely on get_token() which can be None in Docker/Space
    hub_token = token if token is not True else _get_hf_token()
    pipe = None
    last_error = None

    def _load(pid: str, local: bool) -> bool:
        nonlocal pipe, last_error
        try:
            pipe = StableDiffusionPipeline.from_pretrained(
                pid,
                torch_dtype=dtype,
                safety_checker=None,
                token=None if local else hub_token,
                local_files_only=local,
            )
            return True
        except Exception as err:
            last_error = err
            _raise_if_403(err)
            return False

    if _load(load_id, local_only):
        pass
    elif not local_only and _load(FALLBACK_MODEL_ID, False):
        pass
    if pipe is None:
        err_msg = (
            "Could not load Stable Diffusion. Need internet to download the model (first run).\n"
            "  - On Hugging Face Spaces: add HF_TOKEN in Settings β†’ Variables and secrets "
            "(create a token at huggingface.co/settings/tokens, read access is enough).\n"
            "  - Locally: set HF_TOKEN=your_token or run: huggingface-cli download runwayml/stable-diffusion-v1-5 --local-dir ./weights/sd-v1-5"
        )
        raise RuntimeError(err_msg) from last_error

    pipe = pipe.to(device)

    # Optional: enable xformers for lower VRAM (uncomment if installed)
    # if device == "cuda":
    #     pipe.enable_xformers_memory_efficient_attention()

    if device == "cuda":
        torch.cuda.reset_peak_memory_stats()
        torch.cuda.synchronize()

    generator = None
    if seed is not None:
        generator = torch.Generator(device=device).manual_seed(seed)

    num_inference_steps = 50

    def _callback(step_idx: int, t, latents):
        if progress_callback is not None:
            step = min(step_idx + 1, num_inference_steps)
            progress_callback(step, num_inference_steps)

    t0 = time.perf_counter()
    image = pipe(
        prompt=prompt,
        width=width,
        height=height,
        num_inference_steps=num_inference_steps,
        generator=generator,
        callback=_callback if progress_callback else None,
        callback_steps=1 if progress_callback else None,
    ).images[0]

    if device == "cuda":
        torch.cuda.synchronize()
    t1 = time.perf_counter()
    inference_time = t1 - t0
    peak_vram_mb = (
        torch.cuda.max_memory_allocated() / 1024 / 1024
        if device == "cuda"
        else 0.0
    )

    # Save with safe filename
    safe_name = "".join(c if c.isalnum() or c in " -_" else "_" for c in prompt)[:60]
    out_path = os.path.join(output_dir, f"skybox_{safe_name.strip()}.png")
    image.save(out_path)

    return out_path, inference_time, peak_vram_mb