"""Model Gen — photos of one adult → 3D identity mesh + orbit video.""" from __future__ import annotations import os from pathlib import Path from typing import Any from PIL import Image from modelgen.config import APP_NAME, ASSETS, DEFAULT_PORT, REJECTED_ASSETS, running_on_spaces from modelgen.pipeline import build_model_and_video try: import spaces except ImportError: # local / CI class spaces: # type: ignore[no-redef] @staticmethod def GPU(*_args: Any, **_kwargs: Any): def decorator(fn): return fn return decorator def _patch_gradio_json_schema() -> None: """gradio_client 1.3 crashes when additionalProperties is a JSON-schema bool.""" import gradio_client.utils as gcu _get_type = gcu.get_type _convert = gcu._json_schema_to_python_type def get_type(schema): # noqa: ANN001 if not isinstance(schema, dict): return "any" return _get_type(schema) def convert(schema, defs): # noqa: ANN001 if isinstance(schema, bool): return "Any" return _convert(schema, defs) gcu.get_type = get_type # type: ignore[method-assign] gcu._json_schema_to_python_type = convert # type: ignore[method-assign] _patch_gradio_json_schema() def _as_images(files: Any) -> tuple[list[Image.Image], list[str]]: if not files: return [], [] if not isinstance(files, list): files = [files] images: list[Image.Image] = [] names: list[str] = [] for item in files: path = item.name if hasattr(item, "name") else item if not path: continue img = Image.open(path).convert("RGB") images.append(img) names.append(Path(str(path)).name) return images, names def _status_md(engine: str, warnings: list[str], n_photos: int) -> str: lines = [ f"**3D engine:** {engine}", f"**Photos used:** {n_photos}", "**Outputs:** `identity.glb` (3D model) + orbit video of that mesh", ] if warnings: lines.append("") lines.append("**Notes**") lines.extend(f"- {w}" for w in warnings) return "\n".join(lines) EMPTY_STATUS = ( "Upload 2–8 photos of **one consenting adult** (front plus other angles). " "Generate builds a **3D identity mesh** and an **orbit video of that mesh** — not a collage of stills. " "This path runs on CPU so it still works when ZeroGPU quota is exhausted." ) def run_generate(files, adult_ok, progress=None): import gradio as gr if not adult_ok: raise gr.Error("Confirm that every photo is of a consenting adult (18+).") images, names = _as_images(files) if not images: raise gr.Error("Upload at least one photo — a clear front view plus extra angles works best.") if len(images) > 8: raise gr.Error("Use at most 8 photos.") if progress is not None: progress(0.1, desc="Building 3D identity mesh…") try: bundle = build_model_and_video(images, names) except gr.Error: raise except Exception as exc: # noqa: BLE001 raise gr.Error(f"Generation failed: {exc}") from exc if progress is not None: progress(1.0, desc="Done") status = _status_md(bundle.engine, bundle.warnings, len(images)) return str(bundle.glb_path), str(bundle.video_path), status def _license_markdown() -> str: rows = [ "| Role | Source | License | Gated |", "| --- | --- | --- | --- |", ] for asset in ASSETS: rows.append( f"| {asset.role} | [`{asset.repo_id}`]({asset.source}) | {asset.license} | " f"{'yes' if asset.gated else 'no'} |" ) rejected = "\n".join(f"- **{a.repo_id}** ({a.license}): {a.notes}" for a in REJECTED_ASSETS) return ( "### Models and assets (free / OSS)\n\n" + "\n".join(rows) + "\n\nNot used (license, gating, or not 3D):\n\n" + rejected ) def _patch_gradio_localhost_probe() -> None: import gradio.networking as networking networking.url_ok = lambda _url: True # type: ignore[method-assign] def _launch_demo(demo) -> None: demo.queue(max_size=8, api_open=False) _patch_gradio_localhost_probe() if running_on_spaces(): server_name = os.environ.get("GRADIO_SERVER_NAME", "0.0.0.0") server_port = int(os.environ.get("GRADIO_SERVER_PORT") or os.environ.get("PORT") or "7860") else: server_name = "0.0.0.0" server_port = int(os.environ.get("PORT", DEFAULT_PORT)) demo.launch( server_name=server_name, server_port=server_port, share=False, show_error=True, show_api=False, ) def build_demo(): import gradio as gr css = """ .gradio-container {max-width: 1180px !important;} footer {opacity: 0.7;} """ with gr.Blocks(title=APP_NAME, css=css, theme=gr.themes.Soft()) as demo: gr.Markdown( f""" # {APP_NAME} Photos of **one adult** → a **3D identity model** (GLB) → an **orbit video of that model**. This is not a 2D collage. Extra angles improve the bust; the video camera flies around the mesh. Geometry uses **Depth-Anything V2 Small** (Apache-2.0, ungated) when weights load, otherwise a CPU shape-from-shading relief. No InsightFace, no gated FLUX.1-dev, no ZeroGPU required for this slice. """ ) with gr.Row(): with gr.Column(scale=1): files = gr.File( label="Photos of one person (front + other angles / lighting)", file_count="multiple", file_types=["image"], ) adult_ok = gr.Checkbox( label="I confirm every photo is of a consenting adult (18+).", value=False, ) generate_btn = gr.Button("Build 3D model + orbit video", variant="primary") with gr.Column(scale=1): model3d = gr.Model3D(label="3D identity (GLB)") video = gr.Video(label="Orbit video of the 3D model") status = gr.Markdown(EMPTY_STATUS) generate_btn.click( fn=run_generate, inputs=[files, adult_ok], outputs=[model3d, video, status], ) gr.Markdown(_license_markdown()) gr.Markdown( """ ### Video pipeline 1. **Now:** orbit render of the generated GLB (same asset, moving camera). 2. **Next:** import `identity.glb` into Blender / Unreal, or use orbit frames as control for **CogVideoX-2b** (Apache-2.0). Neural image-to-3D (TripoSR MIT, InstantMesh Apache) is stronger mesh quality but TripoSR is Hub-gated and InstantMesh is too heavy for a 60s ZeroGPU slot — documented, not loaded. """ ) return demo def main() -> None: _launch_demo(build_demo()) if __name__ == "__main__": main()