"""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 # ----------------------------------------------------------------------------- # Paths & constants # ----------------------------------------------------------------------------- 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 # Full-frame 35mm equivalent SENSOR_WIDTH_MM: Final[float] = 36.0 # Full-frame 35mm width # Register static paths for Gradio 6 file serving 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) # ----------------------------------------------------------------------------- # FOV / Focal Length utilities # ----------------------------------------------------------------------------- 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) # Load default settings as base 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")) # Merge, preserving existing values but updating FOV if "background" in existing: settings["background"] = existing["background"] if "camera" in existing: settings["camera"] = {**settings["camera"], **existing["camera"]} settings["camera"]["fov"] = fov # Always update 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 # ----------------------------------------------------------------------------- # Validation & file operations # ----------------------------------------------------------------------------- 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}" # ----------------------------------------------------------------------------- # HTML generators # ----------------------------------------------------------------------------- def viewer_url_for_output(ply_filename: str, settings_filename: str) -> str: """URL for the viewer with specific output files.""" # Use absolute paths with /gradio_api/file= prefix for content and settings 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 """