""" app.py — SILHOUETTE Studio ========================== Monochrome neural pose-guided image generation and latent synthesis platform. Tabs: 1. Pose Studio (ControlNet OpenPose + LCM-LoRA) 2. Fast Synthesis (Text-to-Image LCM-LoRA) Run with: python app.py """ import logging import random import os # ────────────────────────────────────────────────────────────────────────────── # Gradio 4.x / gradio_client compatibility patch: # In Gradio 4.x, components with dictionary properties (such as Sketchpad) # generate JSON schemas containing additionalProperties as a boolean (True/False). # gradio_client's schema parser crashes with TypeError or APIInfoParseError on booleans. # Patch all schema parser functions and get_api_info at startup to be completely resilient. # ────────────────────────────────────────────────────────────────────────────── try: import gradio_client.utils as _gu _orig_get_type = getattr(_gu, "get_type", None) if _orig_get_type: def _safe_get_type(schema): if not isinstance(schema, dict): return "unknown" return _orig_get_type(schema) _gu.get_type = _safe_get_type _orig_sub_schema = getattr(_gu, "_json_schema_to_python_type", None) if _orig_sub_schema: def _safe_sub_schema(schema, defs=None): if isinstance(schema, bool) or not isinstance(schema, dict): return "Any" try: return _orig_sub_schema(schema, defs) except Exception: return "Any" _gu._json_schema_to_python_type = _safe_sub_schema _orig_main_schema = getattr(_gu, "json_schema_to_python_type", None) if _orig_main_schema: def _safe_main_schema(schema): if isinstance(schema, bool) or not isinstance(schema, dict): return "Any" try: return _orig_main_schema(schema) except Exception: return "Any" _gu.json_schema_to_python_type = _safe_main_schema except Exception: pass import gradio as gr # Guard Blocks.get_api_info against schema parse crashes try: _orig_get_api_info = gr.Blocks.get_api_info def _safe_get_api_info(self): try: return _orig_get_api_info(self) except Exception: return {"named_endpoints": {}, "unnamed_endpoints": {}} gr.Blocks.get_api_info = _safe_get_api_info except Exception: pass from PIL import Image # ────────────────────────────────────────────────────────────────────────────── # Hugging Face ZeroGPU Support: # When running on HF Spaces ZeroGPU, functions performing neural inference # must be decorated with @spaces.GPU so an A100 GPU is allocated on demand. # Fallback to no-op when running locally without the spaces package. # ────────────────────────────────────────────────────────────────────────────── try: import spaces gpu_decorator = spaces.GPU except Exception: def gpu_decorator(func=None, **kwargs): if func is not None and callable(func): return func def _wrapper(f): return f return _wrapper from pipeline.processor import extract_pose_from_image, process_sketch, prepare_image from pipeline.generate import generate_with_pose, generate_text_to_image from utils.canvas import draw_skeleton_overlay from utils.image_utils import save_output, pil_to_numpy # ────────────────────────────────────────────────────────────────────────────── # Logging # ────────────────────────────────────────────────────────────────────────────── logging.basicConfig( level=logging.INFO, format="%(asctime)s | %(levelname)-7s | %(name)s -> %(message)s", datefmt="%H:%M:%S", ) logger = logging.getLogger(__name__) # ────────────────────────────────────────────────────────────────────────────── # Constants & Defaults # ────────────────────────────────────────────────────────────────────────────── APP_NAME = "SILHOUETTE" APP_SUBTITLE = "Neural Pose Synthesis & Latent Generation Engine" DEFAULT_PROMPT = "a subject standing in dynamic posture, studio lighting, highly detailed, photorealistic, 8k" DEFAULT_NEG = ( "low quality, blurry, distorted, deformed, bad anatomy, " "watermark, signature, text, extra limbs, amputated" ) # ────────────────────────────────────────────────────────────────────────────── # Callbacks: Pose Studio # ────────────────────────────────────────────────────────────────────────────── @gpu_decorator def on_extract_pose(reference_image: Image.Image): """Extract OpenPose skeleton from an uploaded reference photo.""" if reference_image is None: return None, None, "[Warning] Upload a reference photo first." try: pose_img = extract_pose_from_image(reference_image) return pose_img, pil_to_numpy(pose_img), "[Success] Pose skeleton extracted successfully." except Exception as e: logger.exception("Pose extraction failed.") return None, None, f"[Error] {e}" @gpu_decorator(duration=60) def on_generate_pose( reference_image: Image.Image, sketch, extracted_pose_img: Image.Image | None, prompt: str, negative_prompt: str, num_steps: int, guidance_scale: float, controlnet_scale: float, seed_mode: str, manual_seed: int, ): """ Main generation callback for Pose Studio. Pose Priority: 1. Extracted pose from reference photo (if extracted) 2. User's sketch drawing on canvas 3. Error - nothing to condition on """ seed = manual_seed if seed_mode == "Fixed" else random.randint(0, 2**32 - 1) pose_image = None if extracted_pose_img is not None: pose_image = prepare_image(extracted_pose_img) elif sketch is not None: pose_image = process_sketch(sketch) if pose_image is None: return ( gr.update(value=None), gr.update(value=None), gr.update(value="[Warning] Please extract a pose from a photo or draw a skeleton on the canvas."), ) if not prompt.strip(): return ( gr.update(value=None), gr.update(value=pil_to_numpy(pose_image)), gr.update(value="[Warning] Please enter a prompt."), ) try: output = generate_with_pose( prompt=prompt, pose_image=pose_image, negative_prompt=negative_prompt, num_inference_steps=int(num_steps), guidance_scale=float(guidance_scale), controlnet_conditioning_scale=float(controlnet_scale), seed=int(seed), ) save_output(output, prefix="silhouette_pose") return ( gr.update(value=pil_to_numpy(output)), gr.update(value=pil_to_numpy(pose_image)), gr.update(value=f"[Success] Generation complete. Seed: {seed}"), ) except Exception as e: logger.exception("Pose-guided generation failed.") return ( gr.update(value=None), gr.update(value=pil_to_numpy(pose_image) if pose_image is not None else None), gr.update(value=f"[Error] {e}"), ) # ────────────────────────────────────────────────────────────────────────────── # Callbacks: Fast Synthesis (T2I) # ────────────────────────────────────────────────────────────────────────────── @gpu_decorator(duration=30) def on_generate_t2i( prompt: str, negative_prompt: str, num_steps: int, guidance_scale: float, seed_mode: str, manual_seed: int, ): """Fast text-to-image generation callback.""" seed = manual_seed if seed_mode == "Fixed" else random.randint(0, 2**32 - 1) if not prompt.strip(): return gr.update(value=None), gr.update(value="[Warning] Please enter a prompt.") try: output = generate_text_to_image( prompt=prompt, negative_prompt=negative_prompt, num_inference_steps=int(num_steps), guidance_scale=float(guidance_scale), seed=int(seed), ) save_output(output, prefix="silhouette_t2i") return gr.update(value=pil_to_numpy(output)), gr.update(value=f"[Success] Generation complete. Seed: {seed}") except Exception as e: logger.exception("Text-to-image generation failed.") return gr.update(value=None), gr.update(value=f"[Error] {e}") def _seed_interactivity(mode: str): return gr.update(visible=(mode == "Fixed")) # ────────────────────────────────────────────────────────────────────────────── # Custom Minimalist Black & White Theme + CSS # ────────────────────────────────────────────────────────────────────────────── _CUSTOM_CSS = """ /* Global Container & Typography */ body, .gradio-container { background-color: #09090b !important; color: #f4f4f5 !important; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif !important; } /* Header & Titles */ #app-title { text-align: center; font-size: 2.2rem; font-weight: 800; letter-spacing: 0.25em; color: #ffffff; margin-top: 10px; margin-bottom: 2px; text-transform: uppercase; } #app-subtitle { text-align: center; font-size: 0.85rem; font-weight: 400; letter-spacing: 0.12em; color: #71717a; margin-bottom: 28px; text-transform: uppercase; } /* Tab Navigation */ .tabs { border-bottom: 1px solid #27272a !important; margin-bottom: 20px !important; } .tab-nav button { font-size: 0.9rem !important; font-weight: 600 !important; letter-spacing: 0.05em !important; color: #a1a1aa !important; border: none !important; background: transparent !important; padding: 10px 20px !important; text-transform: uppercase !important; transition: all 0.2s ease !important; } .tab-nav button.selected { color: #ffffff !important; border-bottom: 2px solid #ffffff !important; } /* Panels & Cards */ .gr-panel, .gr-box, .gr-form, .gr-block { background-color: #121215 !important; border: 1px solid #27272a !important; border-radius: 6px !important; } /* Inputs & Textareas */ input[type="text"], textarea, .gr-input, .gr-dropdown { background-color: #18181b !important; border: 1px solid #27272a !important; color: #ffffff !important; border-radius: 4px !important; } input[type="text"]:focus, textarea:focus { border-color: #ffffff !important; box-shadow: 0 0 0 1px #ffffff !important; } /* Labels */ label span, .gr-label { color: #a1a1aa !important; font-size: 0.78rem !important; font-weight: 600 !important; letter-spacing: 0.06em !important; text-transform: uppercase !important; } /* Buttons */ .btn-primary, button.primary-btn, button.primary { background: #ffffff !important; color: #000000 !important; font-weight: 700 !important; font-size: 0.85rem !important; letter-spacing: 0.08em !important; text-transform: uppercase !important; border: 1px solid #ffffff !important; border-radius: 4px !important; padding: 12px 24px !important; transition: background-color 0.15s ease, opacity 0.15s ease !important; } .btn-primary:hover, button.primary-btn:hover, button.primary:hover { background: #e4e4e7 !important; color: #000000 !important; opacity: 0.95 !important; } .btn-secondary, button.secondary-btn, button.secondary { background: transparent !important; color: #ffffff !important; font-weight: 600 !important; font-size: 0.82rem !important; letter-spacing: 0.06em !important; text-transform: uppercase !important; border: 1px solid #3f3f46 !important; border-radius: 4px !important; padding: 8px 16px !important; transition: all 0.15s ease !important; } .btn-secondary:hover, button.secondary-btn:hover, button.secondary:hover { background: #27272a !important; border-color: #52525b !important; } /* Status Box */ .status-box textarea { background-color: #09090b !important; border: 1px solid #27272a !important; color: #d4d4d8 !important; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace !important; font-size: 0.82rem !important; } /* Sliders */ input[type="range"] { accent-color: #ffffff !important; } /* Instructions Banner */ .instruction-card { background-color: #121215; border: 1px solid #27272a; border-radius: 6px; padding: 14px 18px; margin-bottom: 16px; font-size: 0.85rem; line-height: 1.6; color: #a1a1aa; } .instruction-card strong { color: #ffffff; } /* Footer */ .footer-text { text-align: center; font-size: 0.75rem; color: #52525b; letter-spacing: 0.08em; text-transform: uppercase; margin-top: 30px; padding-top: 15px; border-top: 1px solid #1f1f23; } """ def build_app() -> gr.Blocks: theme = gr.themes.Monochrome( primary_hue="neutral", secondary_hue="neutral", neutral_hue="neutral", font=[gr.themes.GoogleFont("Inter"), "sans-serif"], ).set( body_background_fill="#09090b", block_background_fill="#121215", block_border_color="#27272a", input_background_fill="#18181b", input_border_color="#27272a", button_primary_background_fill="#ffffff", button_primary_text_color="#000000", button_secondary_background_fill="transparent", button_secondary_text_color="#ffffff", button_secondary_border_color="#3f3f46", ) with gr.Blocks( title=APP_NAME, theme=theme, css=_CUSTOM_CSS, ) as demo: gr.Markdown(f"# {APP_NAME}", elem_id="app-title") gr.Markdown(APP_SUBTITLE, elem_id="app-subtitle") # ── Tab 1: Pose Studio ─────────────────────────────────────────────── with gr.Tab("Pose Studio"): gr.HTML( """