| | """SHARP Gradio demo (PLY export + 3D viewer). |
| | |
| | This Space: |
| | - Runs Apple's SHARP model to predict a 3D Gaussian scene from a single image. |
| | - Exports a canonical `.ply` file for download. |
| | - Serves unique PLY and settings files per generation via the SuperSplat Viewer. |
| | |
| | Uses Gradio 6's static file serving (no FastAPI/uvicorn needed). |
| | """ |
| |
|
| | from __future__ import annotations |
| |
|
| | import json |
| | import math |
| | import time |
| | import uuid |
| | from pathlib import Path |
| | from typing import Final |
| |
|
| | import gradio as gr |
| |
|
| | from model_utils import predict_to_ply_gpu |
| |
|
| | |
| | |
| | |
| |
|
| | APP_DIR: Final[Path] = Path(__file__).resolve().parent |
| | OUTPUTS_DIR: Final[Path] = APP_DIR / "outputs" |
| | VIEWER_DIR: Final[Path] = APP_DIR / "viewer" |
| | DEFAULT_SETTINGS_JSON: Final[Path] = VIEWER_DIR / "settings.default.json" |
| |
|
| | DEFAULT_QUEUE_MAX_SIZE: Final[int] = 32 |
| | DEFAULT_FOCAL_LENGTH_MM: Final[float] = 35.0 |
| | SENSOR_HEIGHT_MM: Final[float] = 24.0 |
| | SENSOR_WIDTH_MM: Final[float] = 36.0 |
| |
|
| | |
| | gr.set_static_paths(paths=[str(VIEWER_DIR), str(OUTPUTS_DIR)]) |
| |
|
| |
|
| | THEME: Final = gr.themes.Origin() |
| |
|
| | CSS: Final[str] = """ |
| | /* Keep layout stable when scrollbars appear/disappear */ |
| | html { scrollbar-gutter: stable; } |
| | |
| | /* Use normal document flow */ |
| | html, body { height: auto; } |
| | body { overflow: auto; } |
| | |
| | /* Full-width layout */ |
| | .gradio-container { |
| | max-width: none; |
| | width: 100%; |
| | margin: 0; |
| | padding: 0.5rem 1rem 1rem; |
| | box-sizing: border-box; |
| | } |
| | |
| | /* Header styling */ |
| | #app-header { |
| | margin-bottom: 0.5rem; |
| | } |
| | #app-header h2 { |
| | margin: 0 0 0.25rem 0; |
| | font-size: 1.5rem; |
| | } |
| | #app-header p { |
| | margin: 0; |
| | opacity: 0.85; |
| | } |
| | |
| | /* Main layout: controls left, viewer right (larger) */ |
| | #main-row { |
| | gap: 1rem; |
| | align-items: stretch; |
| | } |
| | |
| | /* Left panel: controls */ |
| | #controls-panel { |
| | display: flex; |
| | flex-direction: column; |
| | gap: 0.75rem; |
| | } |
| | |
| | #controls-panel .input-image-container { |
| | flex: 1; |
| | min-height: 200px; |
| | } |
| | |
| | #input-image { |
| | width: 100%; |
| | } |
| | #input-image img { |
| | width: 100%; |
| | height: auto; |
| | max-height: 280px; |
| | object-fit: contain; |
| | } |
| | |
| | /* Options row */ |
| | #options-row { |
| | gap: 0.5rem; |
| | } |
| | #options-row > div { |
| | flex: 1; |
| | } |
| | |
| | /* Action buttons */ |
| | #actions-row { |
| | gap: 0.5rem; |
| | } |
| | #actions-row button { |
| | flex: 1; |
| | min-height: 42px; |
| | } |
| | |
| | /* Downloads row */ |
| | #downloads-row { |
| | gap: 0.5rem; |
| | align-items: center; |
| | } |
| | #downloads-row > div { |
| | flex: 1; |
| | } |
| | |
| | /* Right panel: 3D viewer (dominant) */ |
| | #viewer-panel { |
| | display: flex; |
| | flex-direction: column; |
| | min-height: 500px; |
| | } |
| | |
| | #viewer-container { |
| | flex: 1; |
| | display: flex; |
| | flex-direction: column; |
| | min-height: 0; |
| | } |
| | |
| | /* Viewer iframe/placeholder */ |
| | #viewer-html { |
| | flex: 1; |
| | min-height: 500px; |
| | } |
| | |
| | #viewer-html iframe { |
| | width: 100%; |
| | height: 100%; |
| | min-height: 500px; |
| | border: 0; |
| | border-radius: 12px; |
| | overflow: hidden; |
| | background: #000; |
| | } |
| | |
| | /* Placeholder styling */ |
| | .viewer-placeholder { |
| | width: 100%; |
| | height: 100%; |
| | min-height: 500px; |
| | display: flex; |
| | align-items: center; |
| | justify-content: center; |
| | border: 2px dashed var(--border-color-primary, rgba(127, 127, 127, 0.35)); |
| | border-radius: 12px; |
| | background: var(--block-background-fill, rgba(127, 127, 127, 0.05)); |
| | color: var(--body-text-color, rgba(255, 255, 255, 0.92)); |
| | transition: all 0.3s ease; |
| | } |
| | .viewer-placeholder-inner { |
| | max-width: 400px; |
| | padding: 32px; |
| | text-align: center; |
| | } |
| | .viewer-placeholder-icon { |
| | font-size: 48px; |
| | margin-bottom: 16px; |
| | opacity: 0.6; |
| | } |
| | .viewer-placeholder-title { |
| | font-size: 18px; |
| | font-weight: 600; |
| | margin-bottom: 8px; |
| | } |
| | .viewer-placeholder-desc { |
| | font-size: 14px; |
| | line-height: 1.5; |
| | opacity: 0.75; |
| | } |
| | |
| | /* Loading state */ |
| | .viewer-loading { |
| | border-color: var(--primary-500; |
| | background: linear-gradient( |
| | 135deg, |
| | rgba(255, 102, 0, 0.05) 0%, |
| | rgba(255, 102, 0, 0.1) 100% |
| | ); |
| | } |
| | .viewer-loading .viewer-placeholder-icon { |
| | animation: pulse 1.5s ease-in-out infinite; |
| | } |
| | @keyframes pulse { |
| | 0%, 100% { opacity: 0.4; transform: scale(1); } |
| | 50% { opacity: 0.8; transform: scale(1.05); } |
| | } |
| | |
| | /* Status text */ |
| | #status-text { |
| | font-size: 13px; |
| | opacity: 0.85; |
| | margin-top: 0.5rem; |
| | } |
| | |
| | /* Responsive: stack on small screens */ |
| | @media (max-width: 900px) { |
| | #main-row { |
| | flex-direction: column; |
| | } |
| | #controls-panel, #viewer-panel { |
| | min-width: 100% !important; |
| | } |
| | #viewer-html, #viewer-html iframe, .viewer-placeholder { |
| | min-height: 400px; |
| | } |
| | #input-image img { |
| | max-height: 200px; |
| | } |
| | } |
| | """ |
| |
|
| |
|
| | def _ensure_dir(path: Path) -> Path: |
| | path.mkdir(parents=True, exist_ok=True) |
| | return path |
| |
|
| |
|
| | _ensure_dir(OUTPUTS_DIR) |
| | _ensure_dir(VIEWER_DIR) |
| |
|
| |
|
| | |
| | |
| | |
| |
|
| |
|
| | def focal_length_to_fov(focal_length_mm: float, sensor_height_mm: float = SENSOR_HEIGHT_MM, sensor_width_mm: float = SENSOR_WIDTH_MM) -> float: |
| | """Convert focal length (mm) to diagonal field of view (degrees). |
| | |
| | Uses the formula: FOV = 2 * atan(diagonal / (2 * focal_length)) |
| | where diagonal = sqrt(width^2 + height^2) for full-frame 35mm (36x24mm) |
| | """ |
| | if focal_length_mm <= 0: |
| | focal_length_mm = DEFAULT_FOCAL_LENGTH_MM |
| | diagonal_mm = math.sqrt(sensor_width_mm**2 + sensor_height_mm**2) |
| | fov_rad = 2 * math.atan(diagonal_mm / (2 * focal_length_mm)) |
| | return math.degrees(fov_rad) |
| |
|
| |
|
| | def create_settings_file(focal_length_mm: float, output_stem: str) -> Path: |
| | """Create a unique settings.json for this generation.""" |
| | fov = focal_length_to_fov(focal_length_mm) |
| | |
| | |
| | settings = { |
| | "camera": { |
| | "fov": fov, |
| | "position": [0, 0, 0], |
| | "target": [0, 0, 0], |
| | "startAnim": "none", |
| | "animTrack": "" |
| | }, |
| | "background": {"color": [0, 0, 0, 0]}, |
| | "animTracks": [] |
| | } |
| | |
| | if DEFAULT_SETTINGS_JSON.exists(): |
| | try: |
| | existing = json.loads(DEFAULT_SETTINGS_JSON.read_text(encoding="utf-8")) |
| | |
| | if "background" in existing: |
| | settings["background"] = existing["background"] |
| | if "camera" in existing: |
| | settings["camera"] = {**settings["camera"], **existing["camera"]} |
| | settings["camera"]["fov"] = fov |
| | if "animTracks" in existing: |
| | settings["animTracks"] = existing["animTracks"] |
| | except Exception: |
| | pass |
| | |
| | settings_path = OUTPUTS_DIR / f"{output_stem}.settings.json" |
| | settings_path.write_text(json.dumps(settings, indent=2), encoding="utf-8") |
| | return settings_path |
| |
|
| |
|
| | |
| | |
| | |
| |
|
| |
|
| | def _validate_image(image_path: str | None) -> None: |
| | if not image_path: |
| | raise gr.Error("Please upload an image first.") |
| |
|
| |
|
| | def _generate_output_stem() -> str: |
| | """Generate unique output file stem.""" |
| | ts = int(time.time() * 1000) |
| | uid = uuid.uuid4().hex[:8] |
| | return f"scene_{ts}_{uid}" |
| |
|
| |
|
| | |
| | |
| | |
| |
|
| |
|
| | def viewer_url_for_output(ply_filename: str, settings_filename: str) -> str: |
| | """URL for the viewer with specific output files.""" |
| | |
| | content_path = f"/gradio_api/file=outputs/{ply_filename}" |
| | settings_path = f"/gradio_api/file=outputs/{settings_filename}" |
| | return f"/gradio_api/file=viewer/index.html?content={content_path}&settings={settings_path}&noanim" |
| |
|
| |
|
| | def viewer_placeholder_html() -> str: |
| | return """ |
| | <div class="viewer-placeholder"> |
| | <div class="viewer-placeholder-inner"> |
| | <div class="viewer-placeholder-icon">🎨</div> |
| | <div class="viewer-placeholder-title">3D Viewer</div> |
| | <div class="viewer-placeholder-desc"> |
| | Upload an image and click <strong>Generate</strong> to create a 3D Gaussian scene. |
| | The interactive viewer will appear here. |
| | </div> |
| | </div> |
| | </div> |
| | """ |
| |
|
| |
|
| | def viewer_loading_html() -> str: |
| | """Loading placeholder with timer element.""" |
| | return """ |
| | <div class="viewer-placeholder viewer-loading"> |
| | <div class="viewer-placeholder-inner"> |
| | <div class="viewer-placeholder-icon">⚡</div> |
| | <div class="viewer-placeholder-title">Generating 3D Scene...</div> |
| | <div class="viewer-placeholder-desc"> |
| | Running SHARP model inference. This may take a moment. |
| | </div> |
| | <div id="generation-timer" style="font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 28px; font-weight: 600; color: var(--primary-500); margin-top: 16px;">0s</div> |
| | </div> |
| | </div> |
| | """ |
| |
|
| |
|
| | def viewer_iframe_html(ply_filename: str, settings_filename: str) -> str: |
| | src = viewer_url_for_output(ply_filename, settings_filename) |
| | return f""" |
| | <iframe |
| | src="{src}" |
| | allow="xr-spatial-tracking; fullscreen" |
| | referrerpolicy="no-referrer" |
| | loading="eager" |
| | ></iframe> |
| | """ |
| |
|
| |
|
| | |
| | |
| | |
| |
|
| |
|
| | def run_sharp( |
| | image_path: str | None, |
| | focal_length_mm: float, |
| | ) -> tuple[object, object, str, object, object, object, str]: |
| | """Run SHARP inference. |
| | |
| | Returns: (ply_download, viewer_html, status, generate_btn, clear_btn, open_viewer_btn, current_viewer_url) |
| | """ |
| | _validate_image(image_path) |
| |
|
| | try: |
| | |
| | output_stem = _generate_output_stem() |
| | |
| | |
| | settings_path = create_settings_file(focal_length_mm, output_stem) |
| | |
| | |
| | ply_path = predict_to_ply_gpu(image_path) |
| | |
| | |
| | unique_ply_path = OUTPUTS_DIR / f"{output_stem}.ply" |
| | ply_path.rename(unique_ply_path) |
| | |
| | fov = focal_length_to_fov(focal_length_mm) |
| | status = f"✓ Generated **{unique_ply_path.name}** | FOV: {fov:.1f}°" |
| | |
| | viewer_url = viewer_url_for_output(unique_ply_path.name, settings_path.name) |
| |
|
| | return ( |
| | gr.update(value=str(unique_ply_path), visible=True, interactive=True), |
| | viewer_iframe_html(unique_ply_path.name, settings_path.name), |
| | status, |
| | gr.update(interactive=True, value="Generate"), |
| | gr.update(interactive=True), |
| | gr.update(visible=True, interactive=True), |
| | viewer_url, |
| | ) |
| | except gr.Error: |
| | raise |
| | except Exception as e: |
| | raise gr.Error(f"Generation failed: {type(e).__name__}: {e}") from e |
| |
|
| |
|
| | def start_generation() -> tuple[str, object, object]: |
| | """Start generation: show loading state. |
| | |
| | Returns: (viewer_html, generate_btn, clear_btn) |
| | """ |
| | return ( |
| | viewer_loading_html(), |
| | gr.update(interactive=False, value="Generating..."), |
| | gr.update(interactive=False), |
| | ) |
| |
|
| |
|
| | def clear_all() -> tuple: |
| | """Clear all inputs and outputs. |
| | |
| | Returns: (image, ply_download, viewer_html, status, generate_btn, clear_btn, open_viewer_btn, current_viewer_url) |
| | """ |
| | return ( |
| | None, |
| | gr.update(value=None, visible=False), |
| | viewer_placeholder_html(), |
| | "", |
| | gr.update(interactive=True, value="Generate"), |
| | gr.update(interactive=False), |
| | gr.update(visible=False), |
| | "", |
| | ) |
| |
|
| |
|
| | def on_image_change(image_path: str | None) -> tuple[object, object]: |
| | """Handle image upload/removal. |
| | |
| | Returns: (generate_btn, clear_btn) |
| | """ |
| | has_image = bool(image_path) |
| | return ( |
| | gr.update(interactive=has_image, value="Generate"), |
| | gr.update(interactive=has_image), |
| | ) |
| |
|
| |
|
| | |
| | |
| | |
| |
|
| |
|
| | |
| | HEAD_JS: Final[str] = """ |
| | <script> |
| | window.sharpTimer = { |
| | interval: null, |
| | start: function() { |
| | this.stop(); |
| | var startTime = Date.now(); |
| | this.interval = setInterval(function() { |
| | var el = document.getElementById('generation-timer'); |
| | if (!el) return; |
| | var secs = Math.floor((Date.now() - startTime) / 1000); |
| | var mins = Math.floor(secs / 60); |
| | secs = secs % 60; |
| | el.textContent = mins > 0 ? mins + ':' + (secs < 10 ? '0' : '') + secs : secs + 's'; |
| | }, 500); |
| | }, |
| | stop: function() { |
| | if (this.interval) { |
| | clearInterval(this.interval); |
| | this.interval = null; |
| | } |
| | } |
| | }; |
| | window.openSharpViewer = function() { |
| | var iframe = document.querySelector('#viewer-html iframe'); |
| | if (iframe && iframe.src) { |
| | window.open(iframe.src, '_blank'); |
| | } |
| | }; |
| | </script> |
| | """ |
| |
|
| |
|
| | def build_demo() -> gr.Blocks: |
| | with gr.Blocks( |
| | title="SHARP • Single-Image 3D Gaussian Prediction", |
| | elem_id="sharp-root", |
| | ) as demo: |
| | |
| | current_viewer_url = gr.Textbox(value="", visible=False, elem_id="viewer-url-store") |
| | |
| | |
| | with gr.Column(elem_id="app-header"): |
| | gr.Markdown("## SHARP") |
| | gr.Markdown("Single-image **3D Gaussian scene** prediction") |
| |
|
| | |
| | with gr.Row(elem_id="main-row", equal_height=True): |
| | |
| | with gr.Column(scale=3, min_width=280, elem_id="controls-panel"): |
| | |
| | image_in = gr.Image( |
| | label="Input Image", |
| | type="filepath", |
| | sources=["upload"], |
| | elem_id="input-image", |
| | show_label=True, |
| | ) |
| | |
| | |
| | with gr.Row(elem_id="options-row"): |
| | focal_length = gr.Slider( |
| | label="Focal Length (mm)", |
| | minimum=12, |
| | maximum=200, |
| | step=1, |
| | value=DEFAULT_FOCAL_LENGTH_MM, |
| | info="Affects viewer FOV", |
| | ) |
| | |
| | |
| | with gr.Row(elem_id="actions-row"): |
| | generate_btn = gr.Button( |
| | "Generate", |
| | variant="primary", |
| | interactive=False, |
| | elem_id="generate-btn", |
| | ) |
| | clear_btn = gr.Button( |
| | "Clear", |
| | variant="secondary", |
| | interactive=False, |
| | elem_id="clear-btn", |
| | ) |
| | |
| | |
| | with gr.Row(elem_id="downloads-row"): |
| | ply_download = gr.DownloadButton( |
| | label="Download PLY", |
| | value=None, |
| | visible=False, |
| | elem_id="ply-download", |
| | ) |
| | open_viewer_btn = gr.Button( |
| | "Open Viewer in New Tab ↗", |
| | size="sm", |
| | visible=False, |
| | elem_id="open-viewer-btn", |
| | ) |
| | |
| | |
| | status_md = gr.Markdown("", elem_id="status-text") |
| | |
| | |
| | with gr.Column(scale=7, min_width=400, elem_id="viewer-panel"): |
| | viewer_html = gr.HTML( |
| | value=viewer_placeholder_html(), |
| | elem_id="viewer-html", |
| | label="3D Viewer", |
| | ) |
| |
|
| | |
| | with gr.Accordion("About", open=False): |
| | gr.Markdown(""" |
| | ### SHARP Model |
| | **Sharp Monocular View Synthesis in Less Than a Second** (Apple, 2025) |
| | |
| | SHARP predicts a 3D Gaussian splatting scene from a single image, enabling novel view synthesis. |
| | |
| | ```bibtex |
| | @inproceedings{Sharp2025:arxiv, |
| | title = {Sharp Monocular View Synthesis in Less Than a Second}, |
| | 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ël Delaunoy and Tian Fang and Yanghai Tsin and Stephan R. Richter and Vladlen Koltun}, |
| | journal = {arXiv preprint arXiv:2512.10685}, |
| | year = {2025}, |
| | } |
| | ``` |
| | |
| | ### 3D Viewer |
| | Powered by [SuperSplat Viewer](https://github.com/playcanvas/supersplat-viewer) by PlayCanvas. |
| | """.strip()) |
| |
|
| | |
| | |
| | |
| | image_in.change( |
| | fn=on_image_change, |
| | inputs=[image_in], |
| | outputs=[generate_btn, clear_btn], |
| | queue=False, |
| | show_progress="hidden", |
| | ) |
| | |
| | |
| | generate_btn.click( |
| | fn=start_generation, |
| | outputs=[viewer_html, generate_btn, clear_btn], |
| | queue=False, |
| | show_progress="hidden", |
| | js="() => { window.sharpTimer && window.sharpTimer.start(); }", |
| | ).then( |
| | fn=run_sharp, |
| | inputs=[image_in, focal_length], |
| | outputs=[ply_download, viewer_html, status_md, generate_btn, clear_btn, open_viewer_btn, current_viewer_url], |
| | show_progress="hidden", |
| | ).then( |
| | fn=lambda: None, |
| | js="() => { window.sharpTimer && window.sharpTimer.stop(); }", |
| | ) |
| | |
| | |
| | clear_btn.click( |
| | fn=clear_all, |
| | outputs=[image_in, ply_download, viewer_html, status_md, generate_btn, clear_btn, open_viewer_btn, current_viewer_url], |
| | queue=False, |
| | show_progress="hidden", |
| | ) |
| | |
| | |
| | open_viewer_btn.click( |
| | fn=None, |
| | js="() => { window.openSharpViewer(); }", |
| | ) |
| |
|
| | demo.queue(max_size=DEFAULT_QUEUE_MAX_SIZE, default_concurrency_limit=1) |
| | return demo |
| |
|
| |
|
| | demo = build_demo() |
| |
|
| | if __name__ == "__main__": |
| | demo.launch(theme=THEME, css=CSS, head=HEAD_JS) |
| |
|