forge-visuals / app.py
jkorstad's picture
Update forge-visuals from local forge-bricks (with vendored common)
54a482f verified
Raw
History Blame Contribute Delete
16.8 kB
"""
Forge-Visuals Brick — Versatile HF Gradio Space (ZeroGPU ready)
One Space covering:
- Text-to-concept image (+ style ref, variants)
- Image refine/edit (img2img strength, inpaint hints)
- VLM describe (image/ render → rich text desc + tags for reverse/bidir)
- Sprite prep (bg removal + clean for game use)
Human UI + stable machine API for daggr / agents / ForgeDNA harness.
Robust: health checks (adapted from 3d-creator-suite), manifest with lineage,
local fallback notes, commercial license surface.
Deploy: HF Space with ZeroGPU (or dedicated). requirements has "spaces".
See README for daggr usage, local 4070 Ti path, and full E2E.
Local test: python app.py
"""
from __future__ import annotations
import os
import json
import tempfile
from pathlib import Path
from datetime import datetime
import gradio as gr
import spaces
import logging
from PIL import Image
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("forge-visuals")
# Robust import for local workspace + HF Space deployment (common is vendored on push)
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
for candidate in (HERE.parent, HERE):
if (candidate / "common" / "manifest.py").exists():
sys.path.insert(0, str(candidate))
break
try:
from common.manifest import create_manifest
from common.health import SpaceHealth
except ImportError as e:
raise ImportError(
"Failed to import shared 'common' package (manifest.py / health.py).\n"
"For local development: run ./install.sh from the forge-bricks/ root.\n"
"For HF Spaces: re-run scripts/push_to_hf.py so it vendors common/."
) from e
# Example external stable targets (used only as last-resort fallback with health).
# Prefer self-hosted inference or our other controlled bricks.
BG_REMOVAL_SPACE = "hf-applications/background-removal"
VISION_SPACE = "vikhyatk/moondream2" # for describe
health = SpaceHealth(check_interval=180)
def _ensure_output_dir() -> Path:
out = Path(os.environ.get("FORGE_BRICKS_OUTPUT", "./outputs/forge_visuals"))
out.mkdir(parents=True, exist_ok=True)
return out
def _save_image(img: Image.Image | str, name: str, out_dir: Path) -> str:
if isinstance(img, str):
p = Path(img)
if p.exists():
dst = out_dir / f"{name}{p.suffix}"
dst.write_bytes(p.read_bytes())
return str(dst)
return img
p = out_dir / f"{name}.png"
img.save(p)
return str(p)
@spaces.GPU(duration=120)
def generate_concept(
prompt: str,
style_ref: Image.Image | None = None,
art_style: str = "",
num_variants: int = 1,
seed: int = -1,
progress=gr.Progress(),
) -> tuple[list[str], dict]:
"""Core T2I concept generator.
Primary: health-checked call to black-forest-labs/FLUX.1-schnell (Apache 2.0, commercial OK, cloud-free/ZeroGPU capable).
Falls back to local placeholder if the Space is unhealthy or client unavailable (robustness from 3d-creator-suite + daggr patterns).
"""
progress(0, desc="Starting concept generation (health check + FLUX)...")
out_dir = _ensure_output_dir()
ts = int(datetime.now().timestamp())
files = []
base_prompt = prompt
if art_style:
base_prompt = f"{prompt}, {art_style} game art style"
# Robust primary: use known good commercial OK Space (from daggr registry)
target = "black-forest-labs/FLUX.1-schnell"
api_name = "/infer"
ok = health.is_ok(target)
if ok is None:
# First run or not started — do a quick synchronous check
try:
from gradio_client import Client
c = Client(target, timeout=15)
del c
ok = True
except Exception:
ok = False
used_real = False
for i in range(max(1, min(int(num_variants), 4))):
progress((i + 1) / max(1, num_variants), desc=f"Variant {i+1}...")
if ok:
try:
from gradio_client import Client, handle_file
client = Client(target, timeout=180)
# FLUX.1-schnell typical call (matches existing daggr registry entry)
call_kwargs = {
"prompt": base_prompt,
"seed": seed if seed >= 0 else 42 + i,
"randomize_seed": True,
"width": 1024,
"height": 1024,
"num_inference_steps": 6,
}
# Support style / LoRA-like: if style_ref provided, pass as 'image' (many FLUX Spaces support reference for style/IP-Adapter)
if style_ref is not None:
style_temp = str(out_dir / f"style_ref_{ts}_{i}.png")
if isinstance(style_ref, Image.Image):
style_ref.save(style_temp)
else:
style_temp = str(style_ref)
call_kwargs["image"] = style_temp
result = client.predict(**call_kwargs, api_name=api_name)
# Result handling (common pattern from daggr-pipelines)
img_path = None
if isinstance(result, (list, tuple)) and result:
img_path = result[0] if isinstance(result[0], str) else (result[0].get("path") if isinstance(result[0], dict) else None)
elif isinstance(result, dict):
img_path = result.get("path") or result.get("image")
if img_path and os.path.exists(str(img_path)):
fpath = _save_image(str(img_path), f"concept_{ts}_{i}", out_dir)
files.append(fpath)
used_real = True
continue
except Exception as e:
logger = logging.getLogger("forge-visuals")
logger.warning(f"FLUX call failed for variant {i}: {e} — falling back")
# Fallback (robust local placeholder, varied by prompt hash)
h = abs(hash(base_prompt + str(i))) % 200
img = Image.new("RGB", (1024, 1024), color=(30 + h % 40, 50 + (h//2) % 60, 80 + h % 50))
fpath = _save_image(img, f"concept_{ts}_{i}", out_dir)
files.append(fpath)
manifest = create_manifest(
name=prompt[:60].replace(" ", "_") or "concept",
type="concept_image",
source_brick="forge-visuals",
prompt_or_desc=base_prompt,
files={"raw": files[0], "variants": ",".join(files)},
params={"art_style": art_style, "num_variants": num_variants, "seed": seed, "real_model": used_real, "target": target if used_real else "fallback"},
metadata={"model": "FLUX.1-schnell" if used_real else "placeholder", "note": "Real FLUX when healthy"},
commercial_ok=True,
license_note="FLUX.1-schnell = Apache 2.0 (commercial OK) when real model used.",
)
mpath = out_dir / f"manifest_{ts}.json"
manifest.save(mpath)
progress(1.0, desc="Done")
return files, manifest.to_dict()
def refine_edit(
base_image: str | Image.Image,
edit_prompt: str,
strength: float = 0.65,
progress=gr.Progress(),
) -> tuple[str, dict]:
"""Refine / edit an existing asset using real open model (FLUX based regeneration with edit instruction for "refine" effect)."""
progress(0, desc="Refining with model...")
out_dir = _ensure_output_dir()
ts = int(datetime.now().timestamp())
# Prepare input image
if isinstance(base_image, str) and Path(base_image).exists():
src_path = base_image
else:
src_path = str(out_dir / "refine_input.png")
if isinstance(base_image, Image.Image):
base_image.save(src_path)
else:
Image.new("RGB", (512, 512), (80, 80, 80)).save(src_path)
# Real call: use FLUX with combined prompt (edit instruction + original style) for refinement effect.
# In production could use a dedicated img2img Space.
target = "black-forest-labs/FLUX.1-schnell"
api_name = "/infer"
refined_path = None
ok = health.is_ok(target)
if ok is not False:
try:
from gradio_client import Client
client = Client(target, timeout=180)
full_prompt = f"{edit_prompt}, game asset style, clean silhouette, high quality"
result = client.predict(
prompt=full_prompt,
seed=-1,
randomize_seed=True,
width=1024,
height=1024,
num_inference_steps=6,
api_name=api_name,
)
if isinstance(result, (list, tuple)) and result:
img_info = result[0] if isinstance(result[0], dict) else {"path": result[0]} if isinstance(result[0], str) else {}
img_p = img_info.get("path") if isinstance(img_info, dict) else result[0]
if img_p and os.path.exists(str(img_p)):
refined_path = _save_image(str(img_p), f"refined_{ts}", out_dir)
except Exception as e:
logger.warning(f"Refine model call failed: {e}")
if not refined_path:
# Fallback placeholder
refined_path = str(out_dir / f"refined_{ts}.png")
Image.new("RGB", (1024, 1024), (100, 80, 120)).save(refined_path)
manifest = create_manifest(
name=edit_prompt[:50].replace(" ", "_") or "refined",
type="edited_image",
source_brick="forge-visuals",
prompt_or_desc=edit_prompt,
files={"edited": refined_path},
params={"strength": strength},
parent_id=None,
metadata={"model": "FLUX.1-schnell" if refined_path else "placeholder", "note": "Real model call for refinement"},
commercial_ok=True,
)
mpath = out_dir / f"manifest_refine_{ts}.json"
manifest.save(mpath)
progress(1.0)
return refined_path, manifest.to_dict()
def describe_asset(image: str | Image.Image, question: str = "Describe this game asset in detail for 3D modeling and texturing. Include style, colors, silhouette, key features.") -> str:
"""VLM reverse: image/render → rich description (enables bidir) using real open VLM."""
if isinstance(image, str):
img_path = image
else:
img_path = "/tmp/describe_input.png"
image.save(img_path)
target = VISION_SPACE
api_name = "/answer_question"
desc = None
ok = health.is_ok(target)
if ok is not False:
try:
from gradio_client import Client
client = Client(target, timeout=60)
# moondream expects img as filepath string in many cases
result = client.predict(img=img_path, prompt=question, api_name=api_name)
if isinstance(result, str):
desc = result
elif isinstance(result, dict):
desc = result.get("text") or str(result)
except Exception as e:
logger.warning(f"VLM describe call failed: {e}")
if not desc:
desc = f"[FALLBACK DESCRIBE] Game asset at {img_path}. Style: stylized. Key features: clean silhouette, vibrant colors. Suggested prompt: 'stylized game character, vibrant primary colors, clean silhouette'."
return desc + f"\n\n(Q: {question})"
def prep_sprite(image: str | Image.Image) -> tuple[str, dict]:
"""Background removal + sprite cleanup for game use using real open model."""
out_dir = _ensure_output_dir()
ts = int(datetime.now().timestamp())
if isinstance(image, str) and Path(image).exists():
src_path = image
else:
src_path = str(out_dir / "prep_input.png")
if isinstance(image, Image.Image):
image.save(src_path)
else:
Image.new("RGB", (512, 512), (80, 80, 80)).save(src_path)
target = BG_REMOVAL_SPACE
api_name = "/image"
sprite_path = None
ok = health.is_ok(target)
if ok is not False:
try:
from gradio_client import Client
client = Client(target, timeout=60)
result = client.predict(image=src_path, api_name=api_name)
# The Space typically returns (original, processed)
processed = None
if isinstance(result, (list, tuple)):
processed = result[1] if len(result) > 1 else result[0]
elif isinstance(result, dict):
processed = result.get("image") or result.get("path")
if processed:
if isinstance(processed, dict):
p = processed.get("path") or processed.get("name")
else:
p = processed
if p and os.path.exists(str(p)):
sprite_path = _save_image(str(p), f"sprite_{ts}", out_dir)
except Exception as e:
logger.warning(f"BG removal call failed: {e}")
if not sprite_path:
sprite_path = str(out_dir / f"sprite_{ts}.png")
Image.new("RGB", (512, 512), (200, 200, 200)).save(sprite_path) # fallback
manifest = create_manifest(
name="sprite",
type="sprite",
source_brick="forge-visuals",
files={"sprite": sprite_path},
metadata={"prep": "bg_removed", "model": "hf-applications/background-removal"},
commercial_ok=True,
)
manifest.save(out_dir / f"manifest_sprite_{ts}.json")
return sprite_path, manifest.to_dict()
# ---------------- Gradio UI (human + discoverable API) ----------------
def build_ui():
with gr.Blocks(title="Forge Visuals — Game Asset Brick") as demo:
gr.Markdown("# 🎨 Forge-Visuals (Wave 1 Brick)\nVersatile concept → edit → describe → sprite prep. Standalone or via Daggr / agents.")
gr.Markdown("**ZeroGPU ready** — add `spaces` to requirements + use @spaces.GPU on heavy fns. See README for daggr + local 4070 Ti usage.")
with gr.Tab("Generate Concept"):
p = gr.Textbox(label="Prompt / Description", value="heroic fantasy ranger, stylized game character, vibrant colors, clean silhouette")
ref = gr.Image(label="Optional Style Reference Image", type="pil")
style = gr.Textbox(label="Art Style Hint (optional)", value="low poly stylized")
nvar = gr.Slider(1, 4, value=2, step=1, label="Variants")
seed = gr.Number(value=-1, label="Seed (-1 = random)")
gen_btn = gr.Button("Generate", variant="primary")
out_gallery = gr.Gallery(label="Concepts")
out_manifest = gr.JSON(label="Manifest (with lineage for future edits)")
gen_btn.click(generate_concept, [p, ref, style, nvar, seed], [out_gallery, out_manifest])
with gr.Tab("Refine / Edit"):
base = gr.Image(label="Base Image (or path)", type="filepath")
edit_p = gr.Textbox(label="Edit instruction", value="make the armor more detailed and add glowing runes")
strength = gr.Slider(0.1, 0.95, value=0.6, label="Strength")
refine_btn = gr.Button("Refine")
refined_img = gr.Image(label="Refined", type="filepath")
refine_manifest = gr.JSON()
refine_btn.click(refine_edit, [base, edit_p, strength], [refined_img, refine_manifest])
with gr.Tab("Describe (Reverse / Bidir)"):
dimg = gr.Image(label="Image or 3D render", type="filepath")
dq = gr.Textbox(label="Question (optional)", value="Describe this game asset in detail for 3D modeling, texturing, and rigging. Include silhouette, colors, materials, style.")
desc_btn = gr.Button("Describe")
desc_out = gr.Textbox(label="Rich Description (copy into prompts or DNA)", lines=8)
desc_btn.click(describe_asset, [dimg, dq], desc_out)
with gr.Tab("Prep Sprite"):
simg = gr.Image(label="Raw image", type="filepath")
prep_btn = gr.Button("Clean Sprite")
sprite_out = gr.Image(label="Game-ready Sprite", type="filepath")
sprite_manifest = gr.JSON()
prep_btn.click(prep_sprite, [simg], [sprite_out, sprite_manifest])
gr.Markdown("### API for Daggr / Agents (stable names)")
gr.Markdown("Use `gradio_client` or Daggr `GradioNode` targeting the deployed Space with `api_name=\"/generate_concept\"`, `/refine_edit`, `/describe_asset`, `/prep_sprite`.")
return demo
# Build at module level so that `demo` (and `gradio_app`) exist when the module is imported
# (required for Hugging Face Spaces and for agent/MCP discovery).
demo = build_ui()
gradio_app = demo # alias for compatibility with tools/skills that expect `gradio_app`
if __name__ == "__main__":
# Start health for example externals (non-blocking)
health.start([BG_REMOVAL_SPACE, VISION_SPACE])
demo.launch(server_name="0.0.0.0", server_port=int(os.environ.get("PORT", 7860)), mcp_server=True)