RobinsAIWorld/ml-sharp
1
1"""SHARP Gradio demo (minimal, responsive UI).2 3This Space:4- Runs Apple's SHARP model to predict a 3D Gaussian scene from a single image.5- Exports a canonical `.ply` file for download.6- Optionally renders a camera trajectory `.mp4` (CUDA / ZeroGPU only).7 8Precompiled examples9Place precompiled examples under `assets/examples/`.10 11Recommended structure (matching stem):12 assets/examples/<name>.jpg|png|webp13 assets/examples/<name>.mp414 assets/examples/<name>.ply15 16Optional manifest (assets/examples/manifest.json):17 [18 {"label": "Desk", "image": "desk.jpg", "video": "desk.mp4", "ply": "desk.ply"},19 ...20 ]21"""22 23from __future__ import annotations24 25import json26from dataclasses import dataclass27from pathlib import Path28from typing import Final29 30import gradio as gr31 32import os33 34from model_utils import (35 TrajectoryType,36 predict_and_maybe_render_gpu,37 configure_gpu_mode,38 get_gpu_status,39)40from hardware_config import (41 get_hardware_choices,42 parse_hardware_choice,43 get_config,44 update_config,45 SPACES_HARDWARE_SPECS,46 is_running_on_spaces,47)48 49# -----------------------------------------------------------------------------50# Paths & constants51# -----------------------------------------------------------------------------52 53APP_DIR: Final[Path] = Path(__file__).resolve().parent54OUTPUTS_DIR: Final[Path] = APP_DIR / "outputs"55ASSETS_DIR: Final[Path] = APP_DIR / "assets"56EXAMPLES_DIR: Final[Path] = ASSETS_DIR / "examples"57 58IMAGE_EXTS: Final[tuple[str, ...]] = (".png", ".jpg", ".jpeg", ".webp")59DEFAULT_QUEUE_MAX_SIZE: Final[int] = 3260DEFAULT_PORT: Final[int] = int(os.getenv("SHARP_PORT", "49200"))61 62THEME: Final = gr.themes.Soft(63 primary_hue="indigo",64 secondary_hue="blue",65 neutral_hue="slate",66)67 68CSS: Final[str] = """69/* Keep layout stable when scrollbars appear/disappear */70html { scrollbar-gutter: stable; }71 72/* Use normal document flow (no fixed-height viewport shell) */73html, body { height: auto; }74body { overflow: auto; }75 76/* Comfortable max width; still fills small screens */77.gradio-container {78 max-width: 1400px;79 margin: 0 auto;80 padding: 0.75rem 1rem 1rem;81 box-sizing: border-box;82}83 84/* Make media components responsive without stretching */85#run-image, #run-video,86#examples-image, #examples-video {87 width: 100%;88}89 90/* Keep aspect ratio and prevent runaway vertical growth on tall viewports */91#run-image img, #examples-image img {92 width: 100%;93 height: auto;94 max-height: 70vh;95 object-fit: contain;96}97#run-video video, #examples-video video {98 width: 100%;99 height: auto;100 max-height: 70vh;101 object-fit: contain;102}103 104/* On very small screens, reduce max media height a bit */105@media (max-width: 640px) {106 #run-image img, #examples-image img,107 #run-video video, #examples-video video {108 max-height: 55vh;109 }110}111 112/* Reduce extra whitespace in markdown blocks */113.gr-markdown > :first-child { margin-top: 0 !important; }114.gr-markdown > :last-child { margin-bottom: 0 !important; }115"""116 117# -----------------------------------------------------------------------------118# Helpers119# -----------------------------------------------------------------------------120 121 122def _ensure_dir(path: Path) -> Path:123 path.mkdir(parents=True, exist_ok=True)124 return path125 126 127@dataclass(frozen=True, slots=True)128class ExampleSpec:129 """A precompiled example bundle (image + optional mp4 + optional ply)."""130 131 label: str132 image: Path133 video: Path | None134 ply: Path | None135 136 137def _normalize_key(path: str) -> str:138 """Normalize a path-like string for stable dictionary keys."""139 try:140 return str(Path(path).resolve())141 except Exception:142 return path143 144 145def _load_manifest(manifest_path: Path) -> list[dict]:146 """Load manifest.json if present; return an empty list on errors."""147 try:148 data = json.loads(manifest_path.read_text(encoding="utf-8"))149 if not isinstance(data, list):150 raise ValueError("manifest.json must contain a JSON list.")151 return [x for x in data if isinstance(x, dict)]152 except FileNotFoundError:153 return []154 except Exception as e:155 # Manifest errors should not crash the app.156 print(f"[examples] Failed to parse manifest.json: {type(e).__name__}: {e}")157 return []158 159 160def discover_examples(examples_dir: Path) -> list[ExampleSpec]:161 """Discover example bundles under assets/examples/."""162 _ensure_dir(examples_dir)163 164 manifest_rows = _load_manifest(examples_dir / "manifest.json")165 if manifest_rows:166 specs: list[ExampleSpec] = []167 for row in manifest_rows:168 label = str(row.get("label") or "Example").strip() or "Example"169 image_rel = row.get("image")170 if not image_rel:171 continue172 173 image = (examples_dir / str(image_rel)).resolve()174 if not image.exists():175 continue176 177 video = None178 ply = None179 if row.get("video"):180 v = (examples_dir / str(row["video"])).resolve()181 if v.exists():182 video = v183 if row.get("ply"):184 p = (examples_dir / str(row["ply"])).resolve()185 if p.exists():186 ply = p187 188 specs.append(ExampleSpec(label=label, image=image, video=video, ply=ply))189 return specs190 191 # Fallback: infer bundles by filename stem192 images: list[Path] = []193 for ext in IMAGE_EXTS:194 images.extend(sorted(examples_dir.glob(f"*{ext}")))195 196 specs = []197 for img in images:198 stem = img.stem199 video = examples_dir / f"{stem}.mp4"200 ply = examples_dir / f"{stem}.ply"201 specs.append(202 ExampleSpec(203 label=stem.replace("_", " ").strip() or stem,204 image=img.resolve(),205 video=video.resolve() if video.exists() else None,206 ply=ply.resolve() if ply.exists() else None,207 )208 )209 return specs210 211 212_ensure_dir(OUTPUTS_DIR)213 214EXAMPLE_SPECS: Final[list[ExampleSpec]] = discover_examples(EXAMPLES_DIR)215EXAMPLE_INDEX_BY_PATH: Final[dict[str, ExampleSpec]] = {216 _normalize_key(str(s.image)): s for s in EXAMPLE_SPECS217}218EXAMPLE_INDEX_BY_NAME: Final[dict[str, ExampleSpec]] = {219 s.image.name: s for s in EXAMPLE_SPECS220}221 222 223def load_example_assets(224 image_path: str | None,225) -> tuple[str | None, str | None, str | None, str]:226 """Return (image, video, ply_path, status) for the selected example image."""227 if not image_path:228 return None, None, None, "No example selected."229 230 spec = EXAMPLE_INDEX_BY_PATH.get(_normalize_key(image_path))231 if spec is None:232 spec = EXAMPLE_INDEX_BY_NAME.get(Path(image_path).name)233 234 if spec is None:235 return image_path, None, None, "No matching example bundle found."236 237 video = str(spec.video) if spec.video is not None else None238 ply_path = str(spec.ply) if spec.ply is not None else None239 240 missing: list[str] = []241 if video is None:242 missing.append("MP4")243 if ply_path is None:244 missing.append("PLY")245 246 msg = f"Loaded example: **{spec.label}**."247 if missing:248 msg += f" Missing: {', '.join(missing)}."249 250 return str(spec.image), video, ply_path, msg251 252 253def _validate_image(image_path: str | None) -> None:254 if not image_path:255 raise gr.Error("Upload an image first.")256 257 258# -----------------------------------------------------------------------------259# Hardware Configuration260# -----------------------------------------------------------------------------261 262 263def _get_current_hardware_value() -> str:264 """Get current hardware choice value for dropdown."""265 config = get_config()266 if config.mode == "local":267 return "local"268 return f"spaces:{config.spaces_hardware}"269 270 271def _format_gpu_status() -> str:272 """Format GPU status as markdown."""273 status = get_gpu_status()274 config = get_config()275 276 lines = ["### Current Status"]277 lines.append(f"- **Mode:** {'Local CUDA' if config.mode == 'local' else 'HuggingFace Spaces'}")278 279 if config.mode == "spaces":280 hw_spec = SPACES_HARDWARE_SPECS.get(config.spaces_hardware, {})281 lines.append(f"- **Spaces Hardware:** {hw_spec.get('name', config.spaces_hardware)}")282 lines.append(f"- **VRAM:** {hw_spec.get('vram', 'N/A')}")283 lines.append(f"- **Price:** {hw_spec.get('price', 'N/A')}")284 lines.append(f"- **Duration:** {config.spaces_duration}s")285 else:286 lines.append(f"- **CUDA Available:** {'✅ Yes' if status['cuda_available'] else '❌ No'}")287 lines.append(f"- **Spaces Module:** {'✅ Installed' if status['spaces_available'] else '❌ Not installed'}")288 289 if status['devices']:290 lines.append("\n### Local GPUs")291 for dev in status['devices']:292 lines.append(f"- **GPU {dev['index']}:** {dev['name']} ({dev['total_memory_gb']}GB)")293 294 if is_running_on_spaces():295 lines.append("\n⚠️ *Running on HuggingFace Spaces*")296 297 return "\n".join(lines)298 299 300def _apply_hardware_config(choice: str, duration: int) -> str:301 """Apply hardware configuration and return status."""302 mode, spaces_hw = parse_hardware_choice(choice)303 304 # Update config305 update_config(306 mode=mode,307 spaces_hardware=spaces_hw if spaces_hw else "zero-gpu",308 spaces_duration=duration,309 )310 311 # Configure GPU mode in model_utils312 configure_gpu_mode(313 use_spaces=(mode == "spaces"),314 duration=duration,315 )316 317 return _format_gpu_status()318 319 320def run_sharp(321 image_path: str | None,322 trajectory_type: TrajectoryType,323 output_long_side: int,324 num_frames: int,325 fps: int,326 render_video: bool,327) -> tuple[str | None, str | None, str]:328 """Run SHARP inference and return (video_path, ply_path, status_markdown)."""329 _validate_image(image_path)330 out_long_side: int | None = (331 None if int(output_long_side) <= 0 else int(output_long_side)332 )333 334 try:335 video_path, ply_path = predict_and_maybe_render_gpu(336 image_path,337 trajectory_type=trajectory_type,338 num_frames=int(num_frames),339 fps=int(fps),340 output_long_side=out_long_side,341 render_video=bool(render_video),342 )343 344 lines: list[str] = [f"**PLY:** `{ply_path.name}` (ready to download)"]345 if render_video:346 if video_path is None:347 lines.append("**Video:** not rendered (CUDA unavailable).")348 else:349 lines.append(f"**Video:** `{video_path.name}`")350 else:351 lines.append("**Video:** disabled.")352 353 return (354 str(video_path) if video_path is not None else None,355 str(ply_path),356 "\n".join(lines),357 )358 except gr.Error:359 raise360 except Exception as e:361 raise gr.Error(f"SHARP failed: {type(e).__name__}: {e}") from e362 363 364# -----------------------------------------------------------------------------365# UI366# -----------------------------------------------------------------------------367 368 369def build_demo() -> gr.Blocks:370 with gr.Blocks(371 title="SHARP • Single-Image 3D Gaussian Prediction",372 elem_id="sharp-root",373 fill_height=True,374 ) as demo:375 gr.Markdown("## SHARP\nSingle-image **3D Gaussian scene** prediction.")376 377 # Run tab components are referenced by Examples tab, so keep them in outer scope.378 with gr.Column(elem_id="tabs-shell"):379 with gr.Tabs():380 with gr.Tab("Run", id="run"):381 with gr.Column(elem_id="run-panel"):382 with gr.Row(equal_height=True, elem_id="run-media-row"):383 with gr.Column(384 scale=5, min_width=360, elem_id="run-left-col"385 ):386 image_in = gr.Image(387 label="Input image",388 type="filepath",389 sources=["upload"],390 elem_id="run-image",391 )392 393 with gr.Row():394 trajectory = gr.Dropdown(395 label="Trajectory",396 choices=[397 "swipe",398 "shake",399 "rotate",400 "rotate_forward",401 ],402 value="rotate_forward",403 )404 output_res = gr.Dropdown(405 label="Output long side",406 info="0 = match input",407 choices=[408 ("Match input", 0),409 ("512", 512),410 ("768", 768),411 ("1024", 1024),412 ("1280", 1280),413 ("1536", 1536),414 ],415 value=0,416 )417 418 with gr.Row():419 frames = gr.Slider(420 label="Frames",421 minimum=24,422 maximum=120,423 step=1,424 value=60,425 )426 fps_in = gr.Slider(427 label="FPS",428 minimum=8,429 maximum=60,430 step=1,431 value=30,432 )433 434 render_toggle = gr.Checkbox(435 label="Render MP4 (requires CUDA)",436 value=True,437 )438 439 with gr.Column(440 scale=5, min_width=360, elem_id="run-right-col"441 ):442 video_out = gr.Video(443 label="Trajectory video (MP4)",444 elem_id="run-video",445 )446 with gr.Row(elem_id="run-download-row"):447 ply_download = gr.DownloadButton(448 label="Download PLY (.ply)",449 value=None,450 visible=True,451 elem_id="run-ply-download",452 )453 status_md = gr.Markdown("", elem_id="run-status")454 455 with gr.Row(elem_id="run-actions-row"):456 run_btn = gr.Button("Generate", variant="primary")457 clear_btn = gr.ClearButton(458 [image_in, video_out, ply_download, status_md],459 value="Clear",460 )461 462 # Ensure clearing also clears any previous download target.463 clear_btn.click(464 fn=lambda: None,465 outputs=[ply_download],466 queue=False,467 )468 469 run_btn.click(470 fn=run_sharp,471 inputs=[472 image_in,473 trajectory,474 output_res,475 frames,476 fps_in,477 render_toggle,478 ],479 outputs=[video_out, ply_download, status_md],480 api_visibility="public",481 )482 483 with gr.Tab("Examples", id="examples"):484 with gr.Column(elem_id="examples-panel"):485 if EXAMPLE_SPECS:486 gr.Markdown(487 "Click an example to preview precompiled outputs. "488 "The example image will also be loaded into the Run tab."489 )490 491 # Define preview outputs first (unrendered), so we can reference them from gr.Examples.492 ex_img = gr.Image(493 label="Example image",494 type="filepath",495 interactive=False,496 render=False,497 height=360,498 elem_id="examples-image",499 )500 ex_vid = gr.Video(501 label="Pre-rendered MP4",502 render=False,503 height=360,504 elem_id="examples-video",505 )506 ex_ply = gr.DownloadButton(507 label="Download PLY (.ply)",508 value=None,509 visible=True,510 render=False,511 elem_id="examples-ply-download",512 )513 ex_status = gr.Markdown(514 render=False, elem_id="examples-status"515 )516 517 with gr.Row(equal_height=True):518 with gr.Column(scale=4, min_width=320):519 gr.Examples(520 examples=[521 [str(s.image)] for s in EXAMPLE_SPECS522 ],523 example_labels=[s.label for s in EXAMPLE_SPECS],524 inputs=[image_in],525 outputs=[ex_img, ex_vid, ex_ply, ex_status],526 fn=load_example_assets,527 cache_examples=False,528 run_on_click=True,529 examples_per_page=10,530 label=None,531 )532 533 with gr.Column(scale=6, min_width=360):534 ex_img.render()535 ex_vid.render()536 ex_ply.render()537 ex_status.render()538 539 gr.Markdown(540 "Add example bundles under `assets/examples/` "541 "(image + mp4 + ply) or provide a `manifest.json`."542 )543 else:544 gr.Markdown(545 "No precompiled examples found.\n\n"546 "Add files under `assets/examples/`:\n"547 "- `example.jpg` (or png/webp)\n"548 "- `example.mp4`\n"549 "- `example.ply`\n\n"550 "Optionally add `assets/examples/manifest.json` to define labels and filenames."551 )552 553 with gr.Tab("About", id="about"):554 with gr.Column(elem_id="about-panel"):555 gr.Markdown(556 """557*Sharp Monocular View Synthesis in Less Than a Second* (Apple, 2025)558 559```bibtex560@inproceedings{Sharp2025:arxiv,561 title = {Sharp Monocular View Synthesis in Less Than a Second},562 author = {Lars Mescheder and Wei Dong and Shiwei Li and Xuyang Bai and Marcel Santos and Peiyun Hu and Bruno Lecouat and Mingmin Zhen and Ama\\"{e}l Delaunoyand Tian Fang and Yanghai Tsin and Stephan R. Richter and Vladlen Koltun},563 journal = {arXiv preprint arXiv:2512.10685},564 year = {2025},565 url = {https://arxiv.org/abs/2512.10685},566}567```568 """.strip()569 )570 571 with gr.Tab("⚙️ Settings", id="settings"):572 with gr.Column(elem_id="settings-panel"):573 gr.Markdown("### GPU Hardware Selection")574 gr.Markdown(575 "Select local CUDA or HuggingFace Spaces GPU for inference. "576 "Spaces GPUs require deploying to HuggingFace Spaces."577 )578 579 with gr.Row():580 with gr.Column(scale=3):581 hw_dropdown = gr.Dropdown(582 label="Hardware",583 choices=get_hardware_choices(),584 value=_get_current_hardware_value(),585 interactive=True,586 )587 588 duration_slider = gr.Slider(589 label="Spaces GPU Duration (seconds)",590 info="Max time for @spaces.GPU decorator (ZeroGPU only)",591 minimum=60,592 maximum=300,593 step=30,594 value=get_config().spaces_duration,595 interactive=True,596 )597 598 apply_btn = gr.Button("Apply & Save", variant="primary")599 600 with gr.Column(scale=2):601 hw_status = gr.Markdown(602 value=_format_gpu_status(),603 elem_id="hw-status",604 )605 606 apply_btn.click(607 fn=_apply_hardware_config,608 inputs=[hw_dropdown, duration_slider],609 outputs=[hw_status],610 )611 612 gr.Markdown(613 """614---615### Spaces Hardware Reference616 617| Hardware | VRAM | Price | Best For |618|----------|------|-------|----------|619| ZeroGPU (H200) | 70GB | Free (PRO) | Demos, dynamic allocation |620| T4 small/medium | 16GB | $0.40-0.60/hr | Light workloads |621| L4x1 | 24GB | $0.80/hr | Standard inference |622| L40Sx1 | 48GB | $1.80/hr | Large models |623| A10G large | 24GB | $1.50/hr | Balanced cost/performance |624| A100 large | 80GB | $2.50/hr | Maximum VRAM |625 626*Prices as of Dec 2024. See [HuggingFace Spaces GPU docs](https://huggingface.co/docs/hub/spaces-gpus).*627 """628 )629 630 demo.queue(max_size=DEFAULT_QUEUE_MAX_SIZE, default_concurrency_limit=1)631 return demo632 633 634demo = build_demo()635 636if __name__ == "__main__":637 demo.launch(638 theme=THEME,639 css=CSS,640 server_port=DEFAULT_PORT,641 show_api=True642 )643 