Spaces:
Sleeping
Sleeping
File size: 12,441 Bytes
7870459 1eec94c 7870459 1eec94c 7870459 1eec94c 7870459 1eec94c 7870459 1eec94c f7063be 1eec94c f7063be 1eec94c f7063be 1eec94c 1ca25d2 f7063be 1ca25d2 f7063be 1eec94c f7063be 1eec94c f7063be 1ca25d2 f7063be 1ca25d2 f7063be 1ca25d2 f7063be 1ca25d2 f7063be 1ca25d2 f7063be 1ca25d2 7870459 f7063be 1ca25d2 7870459 4ceb07a 7870459 1ca25d2 742dd73 7870459 4ceb07a 7870459 1f36379 1ca25d2 1f36379 f7063be 7870459 1f36379 1ca25d2 1f36379 7870459 1ca25d2 7870459 1eec94c f7063be | 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 | """Portrait-3D — єдиний Gradio ZeroGPU Space (сценарій C, див. ../docs/01).
Чому C замість split (A): щоб списувалась квота ВІДВІДУВАЧА, запит має нести його HF-
ідентичність. HF впорскує її лише у НАТИВНИЙ Gradio-виклик, не в наш сирий fetch. Тож:
three.js-в'ювер живе в <iframe> (весь WebGL без переписування) → на Generate шле паси
батьку через postMessage → батько кладе їх у приховані Gradio-inputs і клікає приховану
кнопку → `@spaces.GPU portrait()` (нативна Gradio-подія = несе HF-токен → per-user квота)
→ результат JSON вертається у прихований textbox → батько postMessage-ить його в iframe.
`pipeline.py`/`passes.py`/`index.html` реюзяться БЕЗ ЗМІН (копіюються сюди `sync.sh`).
"""
from __future__ import annotations
import base64
import html
import io
import json
import os
import time
import gradio as gr
# DEV-режим під Tailscale: якщо задано PORTRAIT_BACKEND (напр. http://pc-gpu:8100), Gradio
# крутиться ЛОКАЛЬНО на Mac, а інференс проксимо на pc-gpu Docker-воркер — без ZeroGPU/torch/
# квоти. Швидка ітерація Gradio-моста без деплою на HF. На HF змінна не задана → ZeroGPU-шлях.
DEV_BACKEND = os.environ.get("PORTRAIT_BACKEND")
def _b64_to_bytes(s: str) -> bytes:
if not s:
return b""
s = s.strip()
if s.startswith("data:") and "," in s:
s = s.split(",", 1)[1]
return base64.b64decode(s)
if DEV_BACKEND:
# локальний dev: проксі на pc-gpu-воркер (той самий /portrait, той самий pipeline/passes,
# той самий контракт відповіді) — тому решта коду/моста однакова що тут, що на HF.
import httpx
_BOOT = f"DEV proxy → {DEV_BACKEND}"
def portrait(normal_b64: str, depth_b64: str, beauty_b64: str, params_json: str = "{}",
seg_b64: str = "") -> str:
if not (normal_b64 and depth_b64 and beauty_b64):
return json.dumps({"error": "normal, depth і beauty — обов'язкові"})
try:
_f = [("normal", normal_b64), ("depth", depth_b64), ("beauty", beauty_b64)]
if seg_b64: # seg-маска регіонів → воркер нарізає per-part prompt
_f.append(("seg", seg_b64))
files = {k: (k + ".png", _b64_to_bytes(v), "image/png") for k, v in _f}
# connect швидко (мертвий/сплячий pc-gpu → чиста помилка за ~6с, не висне), read довго
r = httpx.post(f"{DEV_BACKEND}/portrait", files=files, data={"params": params_json},
timeout=httpx.Timeout(connect=6.0, read=300.0, write=30.0, pool=6.0))
return json.dumps(r.json()) # воркер повертає {image,seed,...} → назад як рядок
except Exception as e:
return json.dumps({"error": f"dev backend unreachable ({type(e).__name__}) — "
f"wake pc-gpu + run recreate.sh"})
else:
# HF: нативний ZeroGPU-шлях (моделі в процесі). import spaces ПЕРШИМ — до torch.
import spaces
import torch
from PIL import Image
import passes
import pipeline
DURATION = int(os.environ.get("GPU_DURATION", "60"))
os.environ.pop("LOW_VRAM", None) # cpu-offload несумісний із fork-моделлю ZeroGPU
try:
pipeline.load()
pipeline.load_img2img()
_BOOT = "models loaded at boot"
except Exception as e:
_BOOT = f"boot load deferred ({type(e).__name__}: {e})"
def _png_data_uri(img) -> str:
buf = io.BytesIO()
img.save(buf, format="PNG")
return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
@spaces.GPU(duration=DURATION)
def portrait(normal_b64: str, depth_b64: str, beauty_b64: str, params_json: str = "{}",
seg_b64: str = "") -> str:
"""G-буфер-паси + params → JSON {image,seed,timing_ms,meta}|{error}."""
t0 = time.perf_counter()
try:
p = json.loads(params_json or "{}")
except json.JSONDecodeError as e:
return json.dumps({"error": f"bad params JSON: {e}"})
if not (normal_b64 and depth_b64 and beauty_b64):
return json.dumps({"error": "normal, depth і beauty — обов'язкові"})
width = int(p.get("width", 832)); height = int(p.get("height", 1216)); size = (width, height)
cn = p.get("controlnet", {}); flip = p.get("normal_flip", {})
try:
normal_img = passes.prep_normal(_b64_to_bytes(normal_b64), flip=flip,
space=p.get("normal_space", "view"), size=size)
depth_img = passes.prep_depth(_b64_to_bytes(depth_b64),
invert=bool(p.get("depth_invert", False)), size=size)
beauty_img = passes.prep_beauty(_b64_to_bytes(beauty_b64), size=size)
except Exception as e:
return json.dumps({"error": f"bad pass image: {type(e).__name__}"})
# regional prompting: seg-маска + parts[{color,prompt}] → регіони (паритет із main.py)
regions = None
if p.get("use_parts") and seg_b64:
parts_meta = [pp for pp in p.get("parts", []) if str(pp.get("prompt", "")).strip()]
if parts_meta:
regions = passes.build_regions(_b64_to_bytes(seg_b64), parts_meta, size)
t_prep = time.perf_counter()
try:
image, seed = pipeline.generate(
normal=normal_img, depth=depth_img, beauty=beauty_img,
prompt=p.get("prompt", ""), negative_prompt=p.get("negative_prompt", ""),
seed=p.get("seed", -1), steps=int(p.get("steps", 30)),
cfg_scale=float(p.get("cfg_scale", 6.0)),
guidance_rescale=float(p.get("guidance_rescale", 0.0)), width=width, height=height,
normal_scale=float(cn.get("normal_scale", 0.6)), depth_scale=float(cn.get("depth_scale", 0.4)),
ip_adapter_scale=float(p.get("ip_adapter_scale", 0.6)),
ip_mode=p.get("ip_mode", "full"), ip_end=float(p.get("ip_end", 1.0)),
denoise=float(p.get("denoise", 1.0)), regions=regions,
)
except torch.cuda.OutOfMemoryError:
return json.dumps({"error": "GPU out of memory — try smaller size or fewer steps"})
except Exception as e:
print(f"generate failed: {type(e).__name__}: {e}", flush=True)
return json.dumps({"error": f"generate failed: {type(e).__name__}"})
t_inf = time.perf_counter()
result = {
"image": _png_data_uri(image), "seed": seed,
"timing_ms": {"preprocess": round((t_prep - t0) * 1000),
"inference": round((t_inf - t_prep) * 1000),
"total": round((t_inf - t0) * 1000)},
"meta": {"base": pipeline.BASE_ID, "controlnet": pipeline.UNION_ID, "ip_adapter": pipeline.IP_WEIGHT},
}
return json.dumps(result)
# --- в'ювер у iframe (srcdoc, self-contained; асети з static-Space через ASSET_BASE) ---
# dev — читаємо ЖИВИЙ ../frontend/index.html (UI-правки без sync); HF — синхронізовану копію.
_HERE = os.path.dirname(__file__)
_VIEWER = next(
open(p, encoding="utf-8").read()
for p in (os.path.join(_HERE, "..", "frontend", "index.html"), os.path.join(_HERE, "index.html"))
if os.path.exists(p)
)
# dev (проксі на pc-gpu) → показати dev-повзунки (Detail/FreeU тощо) у srcdoc-в'ювері,
# де ?dev з URL недоступний. На HF (не dev) — не інжектимо, паблік лишається чистим.
if DEV_BACKEND:
_VIEWER = _VIEWER.replace("<head>", "<head><script>window.__DEV_KNOBS__=1</script>", 1)
IFRAME = (
'<iframe id="pv" title="Portrait-3D viewer" '
'style="width:100%;height:900px;border:0;border-radius:12px;display:block" '
f'srcdoc="{html.escape(_VIEWER, quote=True)}"></iframe>'
)
# міст батька: приймає паси з iframe → приховані inputs → клік прихованого тригера
BRIDGE = """
<script>
function _postIframe(payload){
var f=document.querySelector('#pv');
if(f&&f.contentWindow) f.contentWindow.postMessage({type:'portrait-result', payload:payload}, '*');
}
window.addEventListener('message', function(e){
var d = e.data || {};
if (d.type !== 'portrait-generate') return;
function set(id, val){
var el = document.querySelector('#'+id+' textarea') || document.querySelector('#'+id+' input');
if(!el) return;
var setter = Object.getOwnPropertyDescriptor(el.__proto__, 'value').set;
setter.call(el, val);
el.dispatchEvent(new Event('input', {bubbles:true}));
}
set('h_normal', d.normal); set('h_depth', d.depth); set('h_beauty', d.beauty); set('h_params', d.params);
set('h_seg', d.seg || '');
// gr.Button з elem_id → сам <button> має цей id (не обгортка)
setTimeout(function(){ var b=document.querySelector('#h_trigger, #h_trigger button'); if(b) b.click(); }, 40);
});
// ZeroGPU-помилки (квота тощо) Gradio показує тостом і НЕ пише в h_result → ловимо тост
// і форвардимо в iframe, щоб в'ювер не зависав на «Generating…».
new MutationObserver(function(muts){
for (var i=0;i<muts.length;i++) for (var j=0;j<muts[i].addedNodes.length;j++){
var n=muts[i].addedNodes[j]; if(n.nodeType!==1) continue;
var t=(n.matches&&n.matches('.toast-body.error'))?n:(n.querySelector&&n.querySelector('.toast-body.error'));
if(t){ var msg=(t.textContent||'').replace(/\\s*Error\\s*/,'').replace(/^\\s*×\\s*/,'').trim();
_postIframe({error: msg || 'GPU error'}); }
}
}).observe(document.body, {childList:true, subtree:true});
</script>
"""
# ⚠️ НЕ visible=False — у Gradio 5 такі компоненти не потрапляють у DOM, і міст їх не знайде.
# Рендеримо нормально, ховаємо CSS-ом → лишаються в DOM, подія тригера все одно спрацьовує.
_HIDE = ("#h_normal,#h_depth,#h_beauty,#h_params,#h_seg,#h_result,#h_trigger"
"{display:none!important} footer{display:none!important} "
".gradio-container{max-width:100%!important;padding:0!important}")
# Gradio 6.0 переніс head/css із Blocks() у launch(). Передаємо туди, куди треба за версією
# (HF пиниться на 5.x, Mac-dev = 6.x) — інакше на 6.x міст (head) мовчки не застосується.
_GR_MAJOR = int(gr.__version__.split(".")[0])
_HEADCSS = {"head": BRIDGE, "css": _HIDE}
with gr.Blocks(title="Portrait-3D", **({} if _GR_MAJOR >= 6 else _HEADCSS)) as demo:
gr.HTML(IFRAME)
h_normal = gr.Textbox(elem_id="h_normal")
h_depth = gr.Textbox(elem_id="h_depth")
h_beauty = gr.Textbox(elem_id="h_beauty")
h_params = gr.Textbox(elem_id="h_params")
h_seg = gr.Textbox(elem_id="h_seg")
h_result = gr.Textbox(elem_id="h_result")
h_trigger = gr.Button(elem_id="h_trigger")
# порядок ВІДПОВІДАЄ data-масиву фронту: [normal, depth, beauty, params, seg]
h_trigger.click(portrait, [h_normal, h_depth, h_beauty, h_params, h_seg], h_result, api_name="portrait")
# результат → назад у iframe (js-only подія)
h_result.change(
None, h_result, None,
js="(v)=>{ if(!v) return; var f=document.querySelector('#pv'); "
"if(f&&f.contentWindow) f.contentWindow.postMessage({type:'portrait-result', payload: JSON.parse(v)}, '*'); }",
)
demo.queue(max_size=8).launch(**(_HEADCSS if _GR_MAJOR >= 6 else {}))
|