File size: 10,919 Bytes
2695555
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
"""5.2 (decisão do Enzo, 25/10): gerar imagem com o Bonsai Image 4B (PrismML, Apache 2.0), instalação OPCIONAL e com
confirmação (~9 GB: Python, CUDA e o modelo), sem sudo, em `<dados>/imagem/`. Sem Qt.

- instalar(): clona o programa de demonstração deles num commit fixo e roda o setup deles (que cria o venv, baixa as
  dependências e o modelo). shortcut: o setup deles clona dois repositórios auxiliares pelo ramo, não por commit; se um
  dia quebrar, fixar os commits num fork nosso.
- gerar(): um processo próprio (tars_imagem_trabalhador.py, com o Python do venv deles) mantém o modelo carregado e
  conversa por JSON numa linha; sem portas abertas (não usamos o servidor nem o Studio deles). Antes de gerar, descarrega
  os modelos do Ollama: na RTX 3050 de 6 GB os dois não cabem juntos (medido em 21/10). Depois de alguns minutos parado,
  o processo sai e a placa volta para o Ollama.

Medido em 25/10 pelo caminho do app no PC do Enzo (RTX 3050 6 GB, ~5,0 GB livres com o desktop): a primeira imagem
de 512 px levou 70 s (carga e compilação), as seguintes 5,4 s; um pedido em português também funciona. Em 21/10, com a placa
mais livre: 640 px e acima não cabem."""
import json
import os
import re
import shutil
import subprocess
import sys
import threading
import time
from pathlib import Path

import tars_plataforma as plataforma

REPOSITORIO = "https://github.com/PrismML-Eng/Bonsai-Image-Demo.git"
COMMIT = "de1e5c0323e7a0474c68c6cdd075bad4e782fb0e"
TAMANHO_GB = 9.5                 # medido em 21/10: venv 5,0 + modelo 4,3 + vendor 0,2
LADO_PADRAO = 512                # o que cabe na placa de 6 GB; múltiplo de 32
OCIOSO_S = 300                   # o processo sai depois de 5 min sem pedido (a placa volta para o Ollama)


def pasta():
    return Path(os.environ.get("TARS_PASTA_IMAGEM") or plataforma.pastas()["dados"] / "imagem")


def demo():
    return pasta() / "demo"


def python_do_venv():
    venv = demo() / ".venv"
    return venv / ("Scripts/python.exe" if plataforma.sistema() == "windows" else "bin/python3")


def instalado():
    return python_do_venv().exists() and any((demo() / "models").glob("bonsai-image-4B-*"))


def pode_instalar(vram_mb=None, livre_gb=None):
    """(sim, motivo). Linux e Windows: placa NVIDIA com 6 GB; macOS: Apple Silicon. git e curl presentes; disco."""
    so = plataforma.sistema()
    if so == "macos":
        import platform
        if platform.machine() != "arm64":
            return False, "apple_silicon"
    else:
        if vram_mb is None:
            import tars_primeiro_uso as pu
            vram_mb = pu._vram_mb()
        if vram_mb < 5500:
            return False, "placa"
    for programa in ("git", "curl") if so != "windows" else ("git",):
        if not shutil.which(programa):
            return False, "falta:" + programa
    if livre_gb is None:
        try:
            alvo = pasta()
            while not alvo.exists():
                alvo = alvo.parent
            livre_gb = shutil.disk_usage(alvo).free / 1e9
        except OSError:
            livre_gb = None
    if livre_gb is not None and livre_gb < TAMANHO_GB * 1.2:
        return False, "disco"
    return True, ""


def instalar(ao_linha=None, rodar=None):
    """Clona no commit fixo e roda o setup deles, passando cada linha para `ao_linha`. (ok, erro)."""
    ao_linha = ao_linha or (lambda linha: None)
    rodar = rodar or _rodar
    pasta().mkdir(parents=True, exist_ok=True)
    if not (demo() / ".git").exists():
        ok = rodar(["git", "clone", "--quiet", REPOSITORIO, str(demo())], pasta(), ao_linha)
        if not ok:
            return False, "git clone"
    if not rodar(["git", "-C", str(demo()), "checkout", "--quiet", COMMIT], pasta(), ao_linha):
        return False, "git checkout"
    env = dict(os.environ, BONSAI_VARIANT="ternary")
    if plataforma.sistema() == "windows":
        comando = ["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", str(demo() / "setup.ps1")]
    else:
        comando = ["bash", str(demo() / "setup.sh")]
    if not rodar(comando, demo(), ao_linha, env):
        return False, "setup"
    return (True, "") if instalado() else (False, "modelo")


def _rodar(comando, cwd, ao_linha, env=None):
    proc = subprocess.Popen(comando, cwd=str(cwd), env=env, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
                            stderr=subprocess.STDOUT, text=True, errors="replace")
    for linha in proc.stdout:
        ao_linha(re.sub(r"\x1b\[[0-9;]*m", "", linha.rstrip()))
    return proc.wait() == 0


def remover():
    parar()
    shutil.rmtree(pasta(), ignore_errors=True)


# ------------------------------------------------------------
# Gerar
# ------------------------------------------------------------
_TRABALHADOR = {"proc": None, "ultimo": 0.0}
_TRAVA = threading.Lock()


def descarregar_ollama(host, abrir=None):
    """Tira da placa os modelos que o Ollama tem carregados (keep_alive 0). Eles voltam sozinhos no próximo pedido."""
    import urllib.request
    abrir = abrir or urllib.request.urlopen
    try:
        with abrir(f"{host}/api/ps", timeout=5) as r:
            nomes = [m.get("name") for m in json.loads(r.read()).get("models", [])]
        for nome in nomes:
            pedido = urllib.request.Request(f"{host}/api/generate", data=json.dumps({"model": nome, "keep_alive": 0}).encode(),
                                            headers={"Content-Type": "application/json"})
            with abrir(pedido, timeout=30):
                pass
        return nomes
    except Exception:  # noqa: BLE001  (Ollama fora: nada a descarregar)
        return []


MEMORIA_MINIMA_MB = 4700          # o modelo carregado ocupa 4,67 GB (25/10); abaixo disso nem carrega


def memoria_livre_mb():
    """Memória livre da placa NVIDIA agora (None fora dela ou sem nvidia-smi)."""
    try:
        r = subprocess.run(["nvidia-smi", "--query-gpu=memory.free", "--format=csv,noheader,nounits"], capture_output=True,
                           text=True, timeout=5)
        return max(int(float(x)) for x in r.stdout.split()) if r.returncode == 0 and r.stdout.strip() else None
    except (OSError, ValueError, subprocess.SubprocessError):
        return None


def _trabalhador():
    proc = _TRABALHADOR["proc"]
    if proc is None or proc.poll() is not None:
        script = Path(__file__).resolve().parent / "tars_imagem_trabalhador.py"
        criacao = 0x08000000 if plataforma.sistema() == "windows" else 0            # CREATE_NO_WINDOW
        registro = open(pasta() / "trabalhador.log", "a", encoding="utf-8")       # a saída das bibliotecas, para diagnóstico
        # 25/10: com 5,0 GB livres (o desktop usa ~650 MB da placa de 6 GB) faltavam 256 MB ao codificar o texto, e o PyTorch
        # guardava ~277 MB reservados sem uso; segmentos expansíveis devolvem essa sobra
        env = dict(os.environ, PYTORCH_CUDA_ALLOC_CONF="expandable_segments:True")
        proc = subprocess.Popen([str(python_do_venv()), str(script), str(demo())], cwd=str(demo()), stdin=subprocess.PIPE,
                                stdout=subprocess.PIPE, stderr=registro, text=True, creationflags=criacao, env=env)
        _TRABALHADOR["proc"] = proc
        threading.Thread(target=_vigiar_ocioso, args=(proc,), daemon=True).start()
    return proc


def _vigiar_ocioso(proc):
    while proc.poll() is None:
        time.sleep(15)
        if time.time() - _TRABALHADOR["ultimo"] > OCIOSO_S and not _TRAVA.locked():
            proc.terminate()


def parar():
    proc = _TRABALHADOR["proc"]
    if proc is not None and proc.poll() is None:
        proc.terminate()
    _TRABALHADOR["proc"] = None


def pasta_de_saida():
    """Imagens/openTARS (a pasta de imagens do sistema, em qualquer idioma)."""
    casa = Path.home()
    for nome in ("Pictures", "Imagens", "Imágenes", "Images", "Bilder"):
        if (casa / nome).is_dir():
            return casa / nome / "openTARS"
    return casa / "Pictures" / "openTARS"


def nome_do_arquivo(pedido):
    base = re.sub(r"[^a-z0-9]+", "-", pedido.lower().encode("ascii", "ignore").decode())[:40].strip("-") or "imagem"
    return f"{time.strftime('%Y%m%d-%H%M%S')}-{base}.png"


def gerar(pedido, host=None, lado=LADO_PADRAO, semente=None, saida=None):
    """Gera uma imagem e devolve o caminho do PNG. Levanta RuntimeError com o motivo."""
    if not instalado():
        raise RuntimeError("nao_instalado")
    lado = max(256, min(1024, lado // 32 * 32))
    saida = Path(saida or pasta_de_saida() / nome_do_arquivo(pedido))
    saida.parent.mkdir(parents=True, exist_ok=True)
    with _TRAVA:
        if host:
            descarregar_ollama(host)
        livre = memoria_livre_mb() if _TRABALHADOR["proc"] is None or _TRABALHADOR["proc"].poll() is not None else None
        if livre is not None and livre < MEMORIA_MINIMA_MB:
            # 25/10: um jogo aberto (2,4 GB) já impedia o modelo de carregar; quem usa a placa não é o openTARS
            raise RuntimeError(f"sem_memoria:{livre}")
        proc = _trabalhador()
        _TRABALHADOR["ultimo"] = time.time()
        proc.stdin.write(json.dumps({"prompt": pedido, "lado": lado, "semente": semente, "saida": str(saida)}) + "\n")
        proc.stdin.flush()
        linha = proc.stdout.readline()
        _TRABALHADOR["ultimo"] = time.time()
    if not linha:
        parar()
        raise RuntimeError("o processo do Bonsai parou (memória da placa?)")
    resposta = json.loads(linha)
    if not resposta.get("ok"):
        raise RuntimeError(resposta.get("erro") or "erro")
    return saida


# "desenhe um gato", "gera uma imagem de um farol", "draw a fox", "generate an image of a lighthouse"
_PEDIDO = re.compile(
    r"^\s*(?:(?:por favor|please)[, ]+)?(?:"
    r"(?:desenh[ae]|desenhar|pinte|pinta|ilustr[ae])(?:\s+(?:pra|para) mim)?|"
    r"(?:ger[ae]|gerar|cri[ae]|criar|fa[cç]a|faz|fazer)\s+(?:uma |um )?(?:imagem|figura|ilustra[cç][aã]o|desenho|foto)(?:\s+(?:de|do|da|com))?|"
    r"(?:draw|paint|illustrate)|"
    r"(?:generate|create|make)\s+(?:an? )?(?:image|picture|illustration|drawing)(?:\s+of)?"
    r")\s+(?P<o_que>.+)$", re.IGNORECASE | re.DOTALL)


def pedido_de_imagem(texto):
    """O que desenhar, se o texto pede uma imagem nova ("desenhe um gato na praia" -> "um gato na praia"); senão None."""
    m = _PEDIDO.match(texto or "")
    if not m:
        return None
    o_que = m.group("o_que").strip().rstrip(".!")
    return o_que if len(o_que) >= 3 else None


if __name__ == "__main__":
    # opentars --imagem [--remover]: instala pelo terminal, com as linhas do setup na tela
    if "--remover" in sys.argv:
        remover()
        print("removido")
        sys.exit(0)
    sim, motivo = pode_instalar()
    if not sim:
        print("não dá para instalar aqui:", motivo)
        sys.exit(1)
    ok, erro = instalar(print)
    print("pronto" if ok else f"falhou: {erro}")
    sys.exit(0 if ok else 1)