Catniti's picture
Friendly error message when ZeroGPU daily quota is exhausted
149aa02 verified
Raw
History Blame Contribute Delete
24.5 kB
"""
Multi-Model Image Upscaler — ZeroGPU Space.
Загружает модели лениво (по требованию) через spandrel, поэтому старт Space
быстрый, а квота ZeroGPU тратится только на само инференс-время.
"""
import gc
import os
import sys
import tempfile
import time
import uuid
# --------------------------------------------------------------------------
# Подавление шумных трейсбеков ZeroGPU.
#
# ZeroGPU форкает процессы для выделения GPU, и дочерние процессы наследуют
# asyncio event loop с уже закрытыми файловыми дескрипторами. При сборке мусора
# BaseEventLoop.__del__ пытается закрыть fd = -1 и печатает многострочный
# "Exception ignored in: <function BaseEventLoop.__del__>".
#
# Такие исключения идут не через except, а через sys.unraisablehook, поэтому
# ловим именно его. Всё, что не относится к этому конкретному шуму, пробрасываем
# в стандартный обработчик, чтобы не прятать настоящие ошибки.
# --------------------------------------------------------------------------
_default_unraisablehook = sys.unraisablehook
_NOISY_MESSAGES = ("Invalid file descriptor", "Event loop is closed")
def _quiet_unraisablehook(unraisable):
exc = unraisable.exc_value
obj_repr = repr(unraisable.object)
is_loop_teardown = "BaseEventLoop.__del__" in obj_repr or "eventloop" in obj_repr.lower()
is_noisy_msg = isinstance(exc, ValueError) and any(
m in str(exc) for m in _NOISY_MESSAGES
)
if is_loop_teardown and is_noisy_msg:
return # молча глотаем — это безвредный шум ZeroGPU
_default_unraisablehook(unraisable)
sys.unraisablehook = _quiet_unraisablehook
import gradio as gr
import numpy as np
import spaces
import torch
from huggingface_hub import hf_hub_download
from PIL import Image
from spandrel import ImageModelDescriptor, ModelLoader
# --------------------------------------------------------------------------
# Каталог моделей: имя -> (HF repo, файл весов, множитель, описание)
# Добавить свою модель = дописать строчку сюда.
# --------------------------------------------------------------------------
MODELS = {
"4xNomosWebPhoto_RealPLKSR": (
"Phips/4xNomosWebPhoto_RealPLKSR",
"4xNomosWebPhoto_RealPLKSR.safetensors",
4,
"📷 Фото из интернета — лучший универсальный выбор",
),
"4xNomos2_realplksr_dysample": (
"Phips/4xNomos2_realplksr_dysample",
"4xNomos2_realplksr_dysample.safetensors",
4,
"📷 Фото, чуть резче, быстрый",
),
"4xNomos8kSCHAT-L": (
"Phips/4xNomos8kSCHAT-L",
"4xNomos8kSCHAT-L.safetensors",
4,
"🎯 Максимальное качество (HAT-L, медленный)",
),
"2xNomosUni_span_multijpg": (
"Phips/2xNomosUni_span_multijpg",
"2xNomosUni_span_multijpg.safetensors",
2,
"⚡ Универсальный 2x, очень быстрый",
),
"4xNomosUni_span_multijpg": (
"Phips/4xNomosUni_span_multijpg",
"4xNomosUni_span_multijpg.safetensors",
4,
"⚡ Универсальный 4x, быстрый",
),
"2xHFA2kCompact": (
"Phips/2xHFA2kCompact",
"2xHFA2kCompact.safetensors",
2,
"🎨 Аниме / иллюстрации 2x",
),
"4xFaceUpDAT": (
"Phips/4xFaceUpDAT",
"4xFaceUpDAT.safetensors",
4,
"🙂 Портреты и лица",
),
"1xDeJPG_realplksr_otf": (
"Phips/1xDeJPG_realplksr_otf",
"1xDeJPG_realplksr_otf.safetensors",
1,
"🧹 Только чистка JPEG-артефактов (без увеличения)",
),
}
DEFAULT_MODEL = "4xNomosWebPhoto_RealPLKSR"
# --------------------------------------------------------------------------
# Qwen-Image-Edit-2509 + Upscale LoRA (генеративный апскейл).
#
# Это не spandrel-модель, а LoRA-адаптер к 20B диффузионке, поэтому живёт
# отдельно от MODELS. Веса ~37 ГБ, грузятся в фоновом потоке при старте:
# так восемь быстрых моделей доступны сразу, а Qwen подключается, когда готов.
# --------------------------------------------------------------------------
QWEN_BASE = "Qwen/Qwen-Image-Edit-2509"
QWEN_TRANSFORMER = "linoyts/Qwen-Image-Edit-Rapid-AIO" # дистиллят, 8 шагов
QWEN_LORA_REPO = "vafipas663/Qwen-Edit-2509-Upscale-LoRA"
# Внимание: официальное демо падает с 404, потому что просит
# qwen-edit-enhance_64-v3_000001500.safetensors — автор перенёс этот чекпоинт
# в archive/. Ниже — актуальное имя, проверено что отдаёт 200.
QWEN_LORA_FILE = "qwen-edit-enhance_64-v3_000001000.safetensors"
QWEN_LABEL = "🧠 Qwen-Edit Upscale LoRA (генеративный)"
_qwen_pipe = None
_qwen_status = "not_started" # not_started | loading | ready | failed | disabled
_qwen_error = ""
def _load_qwen():
"""
Загружает Qwen-пайплайн НА УРОВНЕ МОДУЛЯ, В ГЛАВНОМ ПОТОКЕ.
Так требует ZeroGPU: вне @spaces.GPU работает эмуляция CUDA, которая
перехватывает операции и позволяет "положить" модель на cuda без реального
GPU. Эмуляция активна только в главном потоке при импорте — попытка
загрузить модель в фоновом потоке падает с
"Low-level CUDA init (torch._C._cuda_init) reached".
Загрузка блокирует старт примерно на 2-4 минуты (Xet качает ~37 ГБ
на скорости ~700 МБ/с). Ошибка здесь не роняет приложение: быстрые
spandrel-модели продолжат работать, вкладка Qwen покажет причину.
"""
global _qwen_pipe, _qwen_status, _qwen_error
if os.environ.get("ENABLE_QWEN", "1") == "0":
_qwen_status = "disabled"
print("[qwen] отключён через ENABLE_QWEN=0", flush=True)
return
_qwen_status = "loading"
print("[qwen] загрузка (~37 ГБ), это займёт несколько минут...", flush=True)
started = time.time()
try:
from diffusers import QwenImageEditPlusPipeline
from diffusers.models import QwenImageTransformer2DModel
token = os.environ.get("HF_TOKEN") or None
dtype = torch.bfloat16
transformer = QwenImageTransformer2DModel.from_pretrained(
QWEN_TRANSFORMER,
subfolder="transformer",
dtype=dtype,
device_map="cuda" if torch.cuda.is_available() else None,
token=token,
)
pipe = QwenImageEditPlusPipeline.from_pretrained(
QWEN_BASE,
transformer=transformer,
dtype=dtype,
token=token,
)
if torch.cuda.is_available():
pipe = pipe.to("cuda")
# Вплавляем LoRA в веса: инференс быстрее, памяти меньше
pipe.load_lora_weights(
QWEN_LORA_REPO,
weight_name=QWEN_LORA_FILE,
adapter_name="upscale",
token=token,
)
pipe.set_adapters(["upscale"], adapter_weights=[1.0])
pipe.fuse_lora(adapter_names=["upscale"], lora_scale=1.0)
pipe.unload_lora_weights()
_qwen_pipe = pipe
_qwen_status = "ready"
print(f"[qwen] готов за {time.time() - started:.0f} с", flush=True)
except Exception as exc: # noqa: BLE001
_qwen_status = "failed"
_qwen_error = f"{type(exc).__name__}: {exc}"
print(f"[qwen] загрузка не удалась: {_qwen_error}", flush=True)
# Ограничения, чтобы не ловить OOM и не жечь квоту
MAX_INPUT_PIXELS = 1_200_000 # ~1400x850 на входе
MAX_OUTPUT_PIXELS = 20_000_000 # ~5000x4000 на выходе
QWEN_MAX_INPUT_PIXELS = 1_048_576 # ~1024x1024 — больше не влезает в VRAM
TILE_SIZE = 512
TILE_OVERLAP = 32
_cache: dict[str, ImageModelDescriptor] = {}
def _device() -> torch.device:
return torch.device("cuda" if torch.cuda.is_available() else "cpu")
def load_model(name: str) -> ImageModelDescriptor:
"""Скачивает (с кешем на диске Space) и загружает модель."""
if name in _cache:
return _cache[name]
repo, filename, _, _ = MODELS[name]
# HF_TOKEN подхватывается из секретов Space, если он там задан:
# с ним выше rate-limit на скачивание весов. Без него тоже работает.
path = hf_hub_download(
repo_id=repo, filename=filename, token=os.environ.get("HF_TOKEN") or None
)
model = ModelLoader().load_from_file(path)
if not isinstance(model, ImageModelDescriptor):
raise gr.Error(f"Модель {name} не является image-to-image моделью")
model.eval()
# держим в кеше максимум 2 модели, чтобы не забить VRAM
if len(_cache) >= 2:
old = next(iter(_cache))
del _cache[old]
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
_cache[name] = model
return model
def _to_tensor(img: Image.Image) -> torch.Tensor:
arr = np.asarray(img.convert("RGB"), dtype=np.float32) / 255.0
return torch.from_numpy(arr).permute(2, 0, 1).unsqueeze(0)
def _to_image(t: torch.Tensor) -> Image.Image:
arr = t.squeeze(0).permute(1, 2, 0).clamp(0, 1).cpu().numpy()
return Image.fromarray((arr * 255.0 + 0.5).astype(np.uint8))
@torch.inference_mode()
def _run_tiled(model, tensor: torch.Tensor, scale: int) -> torch.Tensor:
"""Прогон по тайлам с перекрытием — иначе большие картинки дают OOM."""
_, _, h, w = tensor.shape
if h <= TILE_SIZE and w <= TILE_SIZE:
return model(tensor)
out = torch.zeros(1, 3, h * scale, w * scale, device=tensor.device)
weight = torch.zeros_like(out)
step = TILE_SIZE - TILE_OVERLAP
for y in range(0, h, step):
for x in range(0, w, step):
y2, x2 = min(y + TILE_SIZE, h), min(x + TILE_SIZE, w)
y1, x1 = max(0, y2 - TILE_SIZE), max(0, x2 - TILE_SIZE)
tile = model(tensor[:, :, y1:y2, x1:x2])
out[:, :, y1 * scale : y2 * scale, x1 * scale : x2 * scale] += tile
weight[:, :, y1 * scale : y2 * scale, x1 * scale : x2 * scale] += 1
return out / weight.clamp(min=1)
def _qwen_duration(image, prompt, steps, *_):
"""
Бюджет GPU-времени под запрос.
Важно: первый вызов после старта Space дополнительно переносит ~40 ГБ весов
из RAM в VRAM, и это занимает больше минуты. Если запросить слишком мало,
ZeroGPU обрывает задачу с "GPU task aborted", поэтому база щедрая.
"""
px = (image.size[0] * image.size[1]) if image is not None else 500_000
return int(min(300, 150 + steps * 5 + px / 20_000))
@spaces.GPU(duration=_qwen_duration)
def upscale_qwen(
image: Image.Image,
prompt: str,
steps: int,
guidance: float,
seed: int,
randomize_seed: bool,
):
if image is None:
raise gr.Error("Загрузите изображение")
if _qwen_status == "disabled":
raise gr.Error("Qwen отключён (ENABLE_QWEN=0). Используйте быстрые модели.")
if _qwen_status == "failed":
raise gr.Error(f"Qwen не загрузился: {_qwen_error}")
if _qwen_pipe is None:
raise gr.Error("Qwen недоступен. Попробуйте перезапустить Space.")
src = image.convert("RGB")
# Qwen работает в latent-пространстве: слишком большой вход = OOM и слив квоты
px = src.size[0] * src.size[1]
if px > QWEN_MAX_INPUT_PIXELS:
ratio = (QWEN_MAX_INPUT_PIXELS / px) ** 0.5
new = (max(1, int(src.width * ratio)), max(1, int(src.height * ratio)))
gr.Info(f"Вход уменьшен до {new[0]}×{new[1]} — иначе не хватит видеопамяти")
src = src.resize(new, Image.LANCZOS)
if randomize_seed:
seed = int(torch.randint(0, 2**31 - 1, (1,)).item())
generator = torch.Generator(device=_device()).manual_seed(int(seed))
full_prompt = (
prompt.strip()
or "Enhance image quality, sharp focus, fine natural detail, high resolution photo"
)
started = time.time()
try:
result = _qwen_pipe(
image=[src],
prompt=full_prompt,
num_inference_steps=int(steps),
true_cfg_scale=float(guidance),
generator=generator,
num_images_per_prompt=1,
).images[0]
except torch.cuda.OutOfMemoryError:
torch.cuda.empty_cache()
raise gr.Error("Не хватило видеопамяти. Возьмите картинку поменьше.")
except Exception as exc: # noqa: BLE001
msg = str(exc)
if "quota" in msg.lower() or "aborted" in msg.lower():
raise gr.Error(
"Дневная квота ZeroGPU исчерпана. Один запуск Qwen стоит "
"1–3 минуты GPU, а у бесплатного аккаунта всего 5 мин в сутки. "
"Квота сбрасывается через 24 ч после первого использования. "
"Быстрые модели на первой вкладке стоят ~2 с и почти не тратят квоту."
) from exc
raise
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
out_path = os.path.join(
tempfile.gettempdir(), f"qwen_{uuid.uuid4().hex[:8]}.png"
)
result.save(out_path, "PNG")
info = (
f"**{src.width}×{src.height}** → **{result.width}×{result.height}** "
f"· Qwen-Edit Upscale LoRA · {int(steps)} шагов · seed `{seed}` "
f"· {time.time() - started:.1f} с"
)
return (src, result), out_path, info, seed
def _duration(image, model_name, *_):
"""Динамическая длительность: короткие задачи = выше приоритет в очереди."""
if image is None:
return 30
px = image.size[0] * image.size[1]
base = 20 if "HAT" in model_name or "DAT" in model_name else 8
return int(min(120, base + px / 20_000))
@spaces.GPU(duration=_duration)
def upscale(image: Image.Image, model_name: str, downscale_after: bool):
if image is None:
raise gr.Error("Загрузите изображение")
src = image.convert("RGB")
px = src.size[0] * src.size[1]
if px > MAX_INPUT_PIXELS:
ratio = (MAX_INPUT_PIXELS / px) ** 0.5
new = (max(1, int(src.width * ratio)), max(1, int(src.height * ratio)))
gr.Info(f"Изображение уменьшено до {new[0]}x{new[1]} перед апскейлом (лимит входа)")
src = src.resize(new, Image.LANCZOS)
scale = MODELS[model_name][2]
if px * scale * scale > MAX_OUTPUT_PIXELS:
raise gr.Error(
f"Результат был бы слишком большим. Возьмите модель с меньшим "
f"множителем (сейчас {scale}x) или уменьшите вход."
)
started = time.time()
model = load_model(model_name).to(_device())
tensor = _to_tensor(src).to(_device())
try:
result = _run_tiled(model, tensor, model.scale)
except torch.cuda.OutOfMemoryError:
torch.cuda.empty_cache()
raise gr.Error("Не хватило видеопамяти. Попробуйте картинку поменьше или модель полегче.")
out = _to_image(result)
if downscale_after:
out = out.resize(src.size, Image.LANCZOS)
del tensor, result
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
# gr.File отдаёт файл по пути, поэтому сохраняем PNG без потерь на диск
out_path = os.path.join(
tempfile.gettempdir(), f"upscaled_{uuid.uuid4().hex[:8]}.png"
)
out.save(out_path, "PNG", optimize=True)
info = (
f"**{src.width}×{src.height}** → **{out.width}×{out.height}** "
f"· модель `{model_name}` · {time.time() - started:.1f} с"
)
return (src, out), out_path, info
# --------------------------------------------------------------------------
# Загрузка Qwen на уровне модуля — обязательное требование ZeroGPU.
# Выполняется до старта UI и блокирует его на несколько минут.
# --------------------------------------------------------------------------
_load_qwen()
# --------------------------------------------------------------------------
# UI
# --------------------------------------------------------------------------
choices = [(f"{k}{v[3]}", k) for k, v in MODELS.items()]
with gr.Blocks(title="Multi-Model Image Upscaler") as demo:
gr.Markdown(
"""
# 🔍 Multi-Model Image Upscaler
Апскейл на ZeroGPU. Несколько моделей под разные типы картинок — фото,
аниме, лица, чистка JPEG. Бесплатно, доступно и как API.
"""
)
with gr.Tabs():
# ------------------------- Быстрые модели -------------------------
with gr.Tab("⚡ Быстрые модели (2–3 с)"):
with gr.Row():
with gr.Column(scale=1):
inp = gr.Image(type="pil", label="Исходное изображение", height=340)
model_dd = gr.Dropdown(
choices=choices,
value=DEFAULT_MODEL,
label="Модель",
info="Начните с 4xNomosWebPhoto_RealPLKSR",
)
downscale = gr.Checkbox(
False,
label="Вернуть исходный размер",
info="Апскейлит и уменьшает обратно — работает как шумодав/деблюр",
)
btn = gr.Button("Upscale", variant="primary")
with gr.Column(scale=2):
slider = gr.ImageSlider(label="До / После", type="pil", height=460)
info_md = gr.Markdown()
download = gr.File(label="Скачать PNG без потерь")
# --------------------------- Qwen LoRA ---------------------------
with gr.Tab(QWEN_LABEL):
gr.Markdown(
"""
Генеративный апскейл на **Qwen-Image-Edit-2509** с
[Upscale LoRA](https://huggingface.co/vafipas663/Qwen-Edit-2509-Upscale-LoRA)
от vafipas663. В отличие от моделей на первой вкладке, он не просто
восстанавливает пиксели, а **дорисовывает** правдоподобные детали
по текстовому описанию.
⚠️ Один запуск съедает **30–60 с** GPU-квоты
(у анонима её всего 2 мин в сутки, у залогиненного — 5 мин).
💡 Модель обучена на фотографиях. Для 2D-артов и иллюстраций
берите `2xHFA2kCompact` с первой вкладки.
"""
)
qwen_state = gr.Markdown()
with gr.Row():
with gr.Column(scale=1):
q_inp = gr.Image(type="pil", label="Исходное изображение", height=300)
q_prompt = gr.Textbox(
label="Описание сцены",
placeholder="a close-up photo of purple petunia flowers, green leaves",
info="Чем детальнее описание, тем лучше результат",
lines=2,
)
with gr.Accordion("Параметры", open=False):
q_steps = gr.Slider(
4, 30, value=8, step=1,
label="Шаги",
info="8 — оптимум для дистиллированного трансформера",
)
q_guidance = gr.Slider(
1.0, 7.0, value=1.0, step=0.1,
label="True CFG scale",
info="1.0 для 8-шагового режима",
)
q_seed = gr.Number(value=0, label="Seed", precision=0)
q_random = gr.Checkbox(True, label="Случайный seed")
q_btn = gr.Button("Сгенерировать апскейл", variant="primary")
with gr.Column(scale=2):
q_slider = gr.ImageSlider(label="До / После", type="pil", height=460)
q_info = gr.Markdown()
q_download = gr.File(label="Скачать PNG")
def _qwen_state_text():
return {
"ready": "✅ **Qwen готов к работе.**",
"disabled": "⚪ Qwen отключён (`ENABLE_QWEN=0`).",
"failed": f"❌ **Qwen не загрузился:** {_qwen_error}",
}.get(_qwen_status, "⏳ Состояние неизвестно.")
demo.load(_qwen_state_text, outputs=qwen_state)
q_btn.click(
upscale_qwen,
inputs=[q_inp, q_prompt, q_steps, q_guidance, q_seed, q_random],
outputs=[q_slider, q_download, q_info, q_seed],
api_name="upscale_qwen",
)
gr.Markdown(
"""
### Использование через API
```python
from gradio_client import Client, handle_file
client = Client("ЗАМЕНИ_НА_СВОЙ/upscaler")
result = client.predict(
image=handle_file("low-res.jpg"),
model_name="4xNomosWebPhoto_RealPLKSR",
downscale_after=False,
api_name="/upscale",
)
print(result)
```
**Квота ZeroGPU:** аноним 2 мин GPU/сутки, бесплатный аккаунт 5 мин,
PRO 40 мин. Передайте `hf_token="hf_..."` в `Client(...)`, чтобы поднять лимит.
"""
)
btn.click(
upscale,
inputs=[inp, model_dd, downscale],
outputs=[slider, download, info_md],
api_name="upscale",
)
if __name__ == "__main__":
demo.queue(max_size=20).launch(ssr_mode=False, theme=gr.themes.Soft())