Spaces:
Running on Zero
Running on Zero
Download modelos/qwen_image.py from ConectaPrimoAI/conecta-primo-gpu: direct link, hf CLI and curl.
- Browser
- Download file 12.6 kB
-
https://huggingface.co/spaces/ConectaPrimoAI/conecta-primo-gpu/resolve/main/modelos/qwen_image.py
- Command line
-
hf download hf://spaces/ConectaPrimoAI/conecta-primo-gpu/modelos/qwen_image.py
-
curl -L -o qwen_image.py https://huggingface.co/spaces/ConectaPrimoAI/conecta-primo-gpu/resolve/main/modelos/qwen_image.py
12.6 kB
| # -*- coding: utf-8 -*- | |
| """ | |
| IMAGEM — Qwen-Image (Qwen/Qwen-Image), via diffusers. | |
| Escolha do dono. Conferido no Hub em 2026-09-15: `Qwen/Qwen-Image` existe, | |
| é `QwenImagePipeline`, Apache-2.0, 316 mil downloads — e ele é GRANDE: 57,7 GB | |
| no total (transformer 41 GB em 9 partes, text-encoder 16,6 GB, VAE 0,25 GB). | |
| Esse número não é curiosidade, é o que decide tudo o que está escrito abaixo. | |
| ── POR QUE ELE NÃO PODE ENTRAR NO z_image.py ─────────────────────────────── | |
| Trocar só o `IMAGE_MODEL` parecia bastar — o `DiffusionPipeline` resolve a | |
| classe pelo `model_index.json` e carregaria o Qwen-Image sem reclamar. O que | |
| não vem junto é a CALIBRAGEM, e ela é o modelo: | |
| - o z_image pede 8 passos, que é o ponto doce do Z-Image-**Turbo**. O | |
| Qwen-Image base é um modelo de 50 passos; com 8 ele devolve borrão. | |
| - o z_image não passa `true_cfg_scale`. O Qwen-Image usa CFG de verdade | |
| (4.0 é o do cartão do modelo); sem ele o prompt é seguido pela metade. | |
| É a mesma família do defeito que este arquivo existe para não repetir: dois | |
| modelos no mesmo módulo, com os números de um só, é o modelo novo entregando | |
| o pior de si e ninguém sabendo dizer por quê. | |
| ── PASSOS E CFG ANDAM JUNTOS, SEMPRE ─────────────────────────────────────── | |
| O par é a coisa mais fácil de errar aqui, então ele mora num lugar só | |
| (`_receita`). Com o LoRA Lightning o modelo é DESTILADO: ele quer poucos | |
| passos e CFG DESLIGADO (1.0) — pôr 4.0 em cima de um destilado queima a | |
| imagem. Sem o LoRA é o contrário. Mexer num sem o outro é o defeito. | |
| ── O LIGHTNING É O PADRÃO, E ISSO É CONTA, NÃO GOSTO ─────────────────────── | |
| `lightx2v/Qwen-Image-Lightning` (0,85 GB, bf16, 8 passos V2.0) é um LoRA que | |
| roda EM CIMA destes mesmos pesos. Ele é o padrão porque 50 passos de um | |
| modelo de 20B não cabem no prazo de GPU que esta casa tem: o ZeroGPU gratuito | |
| recusou 240 s declarados e 180 é o maior valor provado (ver CLAUDE.md), e | |
| esse prazo ainda precisa cobrir o carregamento do disco. Um modelo que estoura | |
| o prazo não é um modelo mais bonito — é uma geração que falha. | |
| `IMAGE_LIGHTNING=0` desliga e devolve a receita de 50 passos, para o dia em | |
| que houver GPU dedicada. O LoRA é reserva de si mesmo: se ele não baixar, a | |
| receita cai para a completa em vez de gerar com 8 passos sem destilação, que | |
| seria o borrão do primeiro parágrafo por outro caminho. | |
| ── O QUE NÃO FOI VALIDADO, E É HONESTO DIZER ─────────────────────────────── | |
| A inferência NÃO rodou: não há GPU nem pesos neste ambiente. O que está | |
| provado é o que dá para provar aqui — que o id existe, que a classe do | |
| pipeline é a esperada, e a escolha de módulo por família (t_modelo_imagem.py). | |
| O que conferir no primeiro deploy com GPU está nomeado em `docs/MODELS.md`. | |
| """ | |
| from __future__ import annotations | |
| import io | |
| import logging | |
| import os | |
| from typing import Dict, Optional, Tuple | |
| from . import base | |
| logger = logging.getLogger("worker.qwen_image") | |
| # Com offload. O modelo inteiro em VRAM quer bem mais; o offload troca | |
| # velocidade por caber, e caber ganha — imagem lenta ainda é imagem. | |
| VRAM_MINIMA_GB = 12 | |
| # O LoRA de destilação. Repositório e ARQUIVO, porque o repositório tem doze | |
| # arquivos e a escolha entre 4 e 8 passos é dele: 8 passos V2.0 é o meio-termo | |
| # medido pelos autores entre velocidade e fidelidade, e o bf16 (0,85 GB) é | |
| # metade do download do fp32 sem diferença que se veja na tela. | |
| _LORA_REPO = "lightx2v/Qwen-Image-Lightning" | |
| _LORA_ARQUIVO = "Qwen-Image-Lightning-8steps-V2.0-bf16.safetensors" | |
| _pipe = None | |
| _modelo_carregado = "" | |
| _lora_vivo = False # o LoRA ENTROU? Não é o mesmo que "foi pedido". | |
| def _modelo() -> str: | |
| """O PADRÃO TEM DE SER O QUE CABE, e isto é conserto de defeito. | |
| Ele era `Qwen/Qwen-Image` — 57,7 GB, num disco de Space de 50. O backend | |
| manda o id escolhido dentro do pedido, mas quem lê aqui é a VARIÁVEL de | |
| ambiente do processo: sem `IMAGE_MODEL` configurado no Space, o padrão | |
| escrito nesta linha é quem manda, e ele mandava baixar 57,7 GB. O disco | |
| enche, a geração falha lá dentro, e o app degrada para o gerador de | |
| reserva sem erro em lugar nenhum — o sintoma que o dono já relatou duas | |
| vezes ("continua indo pro gerador de sempre"). | |
| Padrão que não cabe é o mesmo que padrão que não existe. Este é o FP8 de | |
| 30,1 GB (ver a tabela no `backend/geracao/registro.py`), o mesmo id que o | |
| registro usa: dois padrões diferentes para a mesma escolha seriam dois | |
| donos, e o que fica para trás é sempre o que ninguém abre — e o Space é | |
| justamente esse lado. | |
| """ | |
| return os.getenv("IMAGE_MODEL", "Ilus-AI/Qwen-Image-2512-FP8").strip() | |
| def _quer_lightning() -> bool: | |
| return (os.getenv("IMAGE_LIGHTNING", "1").strip() or "1") != "0" | |
| def _receita(params: Dict) -> Tuple[int, float]: | |
| """(passos, true_cfg_scale) — O PAR, num lugar só. | |
| Quem manda é o LoRA que de fato ENTROU (`_lora_vivo`), nunca o que foi | |
| pedido: o download do LoRA pode falhar, e nesse caso gerar com 8 passos | |
| sem destilação devolve borrão. Errar para a receita completa custa tempo; | |
| errar para a curta custa a imagem. | |
| """ | |
| pedido = params.get("steps") | |
| if _lora_vivo: | |
| passos = int(pedido or 8) | |
| # CFG DESLIGADO: o destilado já traz a orientação nos pesos, e somar | |
| # CFG por cima satura a imagem. 1.0 é "sem CFG" no Qwen-Image. | |
| return max(4, min(passos, 12)), 1.0 | |
| # SEM O LORA, OS 50 PASSOS DO CARTÃO NÃO CABEM NO PRAZO — e esta era a | |
| # metade que faltava do parágrafo do Lightning lá em cima. Ele diz que a | |
| # falha do LoRA "cai para a receita completa em vez de gerar borrão", e | |
| # isso está certo sobre a QUALIDADE e errado sobre o DESFECHO: num teto | |
| # duro de 180 s (ZeroGPU gratuito), 50 passos de um modelo de 20B é a | |
| # chamada abortada no meio, com a quota do dia gasta e NENHUMA imagem — | |
| # e o sintoma não aponta para cá, é a geração caindo no gerador de | |
| # reserva como se a GPU estivesse dormindo. | |
| # | |
| # Então o teto do padrão é o que cabe. 24 passos sem destilação é uma | |
| # imagem pior que 50; é infinitamente melhor que imagem nenhuma — a mesma | |
| # conta do 480p do `wan22.py`. `GPU_DEDICADA=1` devolve os 50 do cartão, | |
| # e é a MESMA variável que o vídeo lê, porque o fato é um só. | |
| # PADRÃO e TETO são dois números, e não um: com GPU de verdade o padrão é | |
| # o do cartão (50) e o teto dá folga para quem pedir mais (60). Sem ela os | |
| # dois colapsam no que cabe — não adianta aceitar um pedido de 50 passos | |
| # que vai ser abortado no meio. | |
| padrao, teto = (50, 60) if base.gpu_dedicada() else (24, 24) | |
| return max(20, min(int(pedido or padrao), teto)), 4.0 | |
| def baixar(progresso: base.Progresso) -> None: | |
| base.baixar_repo(_modelo(), progresso) | |
| if _quer_lightning(): | |
| # A FALHA AQUI NÃO DERRUBA NADA. O LoRA é aceleração, não o modelo; | |
| # sem ele a `_receita` volta para os 50 passos e a imagem sai igual, | |
| # só mais devagar. Derrubar o job por causa do acelerador seria | |
| # trocar "mais lento" por "nenhuma imagem". | |
| try: | |
| # SÓ O ARQUIVO, e isto não é detalhe: o repositório do Lightning | |
| # tem 65,5 GB em doze variantes, e o que se usa aqui pesa 0,85. | |
| base.baixar_repo(_LORA_REPO, progresso, arquivos=[_LORA_ARQUIVO]) | |
| except Exception as e: | |
| logger.warning("LoRA Lightning não baixou (%s) — segue sem ele", e) | |
| def carregar(progresso: base.Progresso) -> None: | |
| global _pipe, _modelo_carregado, _lora_vivo | |
| if base.modo_mock(): | |
| _pipe = "mock" | |
| _lora_vivo = _quer_lightning() | |
| return | |
| if _pipe is not None and _modelo_carregado == _modelo(): | |
| return | |
| progresso(None, "Carregando o modelo de imagem…") | |
| import torch | |
| from diffusers import DiffusionPipeline | |
| # `DiffusionPipeline` e não `QwenImagePipeline` importado por nome: a | |
| # classe sai do `model_index.json` do próprio repositório, então um | |
| # `Qwen-Image-2512` com pipeline própria carrega sem tocar neste arquivo. | |
| # Importar a classe por nome quebraria com um ImportError num diffusers | |
| # mais novo que a renomeasse — e esta casa já pagou por id e classe | |
| # escritos à mão. | |
| _pipe = DiffusionPipeline.from_pretrained( | |
| _modelo(), torch_dtype=torch.bfloat16, | |
| token=os.getenv("HF_TOKEN") or None) | |
| _lora_vivo = False | |
| if _quer_lightning(): | |
| try: | |
| progresso(None, "Carregando o acelerador…") | |
| _pipe.load_lora_weights(_LORA_REPO, weight_name=_LORA_ARQUIVO) | |
| _lora_vivo = True | |
| except Exception as e: | |
| logger.warning("LoRA Lightning não entrou (%s) — receita completa", e) | |
| livre = base.vram_livre_gb() | |
| if livre is not None and livre < 36: | |
| # O CORTE ACOMPANHA O MODELO, senão ele vira número solto. Eram 60, | |
| # que era a leitura certa para os 57,7 GB do Qwen-Image cheio; o | |
| # padrão de hoje é o fp8 de 30,1 GB, e manter 60 ligaria o offload em | |
| # QUALQUER placa — inclusive naquelas em que os pesos cabem inteiros. | |
| # O offload sobe blocos da RAM a cada passo, e num prazo de 180 s isso | |
| # é a diferença entre terminar e ser abortado (a mesma conta do | |
| # `wan22.py`). 36 = os 30,1 dos pesos mais folga para ativações. | |
| _pipe.enable_model_cpu_offload() | |
| else: | |
| _pipe.to(base.dispositivo()) | |
| _modelo_carregado = _modelo() | |
| def gerar(params: Dict, progresso: base.Progresso) -> base.Saida: | |
| if base.modo_mock(): | |
| return base.PNG_1X1, "image/png" | |
| prompt = (params.get("prompt") or "").strip() | |
| estilo = (params.get("style") or "").strip() | |
| if estilo: | |
| prompt = f"{prompt}, {estilo} style" | |
| largura = int(params.get("width") or 1328) | |
| altura = int(params.get("height") or 1328) | |
| # A tela manda aspect_ratio; quem manda width/height explícito ganha. | |
| if not params.get("width") and params.get("aspect_ratio"): | |
| largura, altura = _do_aspecto(params["aspect_ratio"]) | |
| passos, cfg = _receita(params) | |
| def a_cada_passo(pipe, passo, t, kw): | |
| progresso(int(100 * (passo + 1) / max(1, passos)), "Gerando…") | |
| return kw | |
| saida = _pipe(prompt=prompt, | |
| # O Qwen-Image quer uma STRING no negativo, e não None: o | |
| # pipeline dele monta o par de embeddings sempre, e " " é o | |
| # vazio que o cartão do modelo usa. | |
| negative_prompt=(params.get("negative_prompt") or " "), | |
| width=largura, height=altura, | |
| num_inference_steps=passos, | |
| true_cfg_scale=cfg, | |
| generator=base.semente(params), | |
| callback_on_step_end=a_cada_passo) | |
| img = saida.images[0] | |
| buf = io.BytesIO() | |
| img.save(buf, format="PNG") | |
| return buf.getvalue(), "image/png" | |
| def descarregar() -> None: | |
| global _pipe, _lora_vivo | |
| _pipe = None | |
| _lora_vivo = False | |
| base.limpar_vram() | |
| def config_menor(params: Dict) -> Optional[Dict]: | |
| """Depois de um OOM: menos pixels primeiro, depois menos passos. | |
| O piso dos passos depende da receita e por isso NÃO é um número escrito | |
| aqui: com o Lightning, 4 é o mínimo que ainda desenha; sem ele, cortar | |
| para 4 devolveria borrão — que é uma falha pior que a falha limpa, porque | |
| ela parece ter dado certo. | |
| """ | |
| p = dict(params) | |
| w = int(p.get("width") or 1328) | |
| if w > 640: | |
| p["width"] = max(640, w // 2) | |
| p["height"] = max(640, int(p.get("height") or 1328) // 2) | |
| return p | |
| passos, _ = _receita(p) | |
| piso = 4 if _lora_vivo else 20 | |
| if passos > piso: | |
| p["steps"] = piso | |
| return p | |
| return None | |
| def _do_aspecto(ratio: str): | |
| """As medidas OFICIAIS do cartão do Qwen-Image, e não as do Z-Image. | |
| Elas não são 1024-e-múltiplos: o modelo foi treinado nestas, e sair delas | |
| custa composição — é o motivo de esta tabela existir em vez de uma conta | |
| de proporção. | |
| """ | |
| tabela = {"1:1": (1328, 1328), "16:9": (1664, 928), "9:16": (928, 1664), | |
| "4:3": (1472, 1140), "3:4": (1140, 1472)} | |
| return tabela.get(str(ratio).strip(), (1328, 1328)) | |